Compare commits
303 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50621f9a15 | ||
|
|
1fca1f80a2 | ||
|
|
46dae8a826 | ||
|
|
e5fe2fc5c6 | ||
|
|
0ab85d888b | ||
|
|
48c82aa07b | ||
|
|
1299f191e2 | ||
|
|
9aed929b67 | ||
|
|
cb9962f0c2 | ||
|
|
262c792654 | ||
|
|
60f1db1f99 | ||
|
|
8cf4f7c2e4 | ||
|
|
6b10daeeac | ||
|
|
74b473e3d7 | ||
|
|
f1b3e8c2d8 | ||
|
|
c78a1d69dc | ||
|
|
5131c8da31 | ||
|
|
eb106c9ca7 | ||
|
|
e742c36ef6 | ||
|
|
9ac01f7cc2 | ||
|
|
a2a7d547ee | ||
|
|
3c99030546 | ||
|
|
f75a960179 | ||
|
|
811baf78da | ||
|
|
43122c20cb | ||
|
|
f90d4b282e | ||
|
|
1eb833f333 | ||
|
|
b2264de949 | ||
|
|
dd6331c098 | ||
|
|
9d687ba9f9 | ||
|
|
1ea95f8fe0 | ||
|
|
65846911f3 | ||
|
|
75dd8cb08d | ||
|
|
db6a3225db | ||
|
|
8b05451f42 | ||
|
|
aa9c47ecc8 | ||
|
|
0e6efc9170 | ||
|
|
64dbce2a00 | ||
|
|
a1f9253712 | ||
|
|
3a6a70a1f7 | ||
|
|
edd96b05fe | ||
|
|
6d5fb9d8c8 | ||
|
|
1f1b7aeab5 | ||
|
|
22bba5cfcd | ||
|
|
4248d8af72 | ||
|
|
f86105a1be | ||
|
|
ae445a78ae | ||
|
|
c3fac5b0ad | ||
|
|
03b180fe88 | ||
|
|
b234db0472 | ||
|
|
7c3a8e7651 | ||
|
|
7fb9d74515 | ||
|
|
dff203d526 | ||
|
|
86584a53a8 | ||
|
|
1d5219eac4 | ||
|
|
6e021fb23a | ||
|
|
bdac5e42ad | ||
|
|
18b88672ec | ||
|
|
8fa061187e | ||
|
|
610915b2a2 | ||
|
|
78ac5d663d | ||
|
|
826c0827dc | ||
|
|
7a75ffed76 | ||
|
|
1299bd5938 | ||
|
|
8f28a99e00 | ||
|
|
7007491d8c | ||
|
|
629f0183f7 | ||
|
|
72cd6f5bbc | ||
|
|
1d44bbb1bd | ||
|
|
a02f6cdcd7 | ||
|
|
817749889a | ||
|
|
a8b9133b80 | ||
|
|
510ab1d2d5 | ||
|
|
ad999c47ea | ||
|
|
9862a51ac7 | ||
|
|
df260d5c64 | ||
|
|
096f66eb15 | ||
|
|
0b33f323ee | ||
|
|
334b624063 | ||
|
|
503ce49ef7 | ||
|
|
f5a30c71b7 | ||
|
|
720f90299a | ||
|
|
0e988a9d42 | ||
|
|
8cb179a8a1 | ||
|
|
05c1bf750a | ||
|
|
a7ab5e6e69 | ||
|
|
24b2dc0460 | ||
|
|
9ecf7f4dfc | ||
|
|
01bfc59849 | ||
|
|
03616f0728 | ||
|
|
7090f9a0e0 | ||
|
|
d4617a96d1 | ||
|
|
b9dda9a938 | ||
|
|
d6b1949c84 | ||
|
|
c16a9ca602 | ||
|
|
30e301830a | ||
|
|
4b893b4808 | ||
|
|
df681be626 | ||
|
|
cc118ffb16 | ||
|
|
407bfbd5f1 | ||
|
|
a52d481a8e | ||
|
|
70d813ee70 | ||
|
|
d99f4544d2 | ||
|
|
22ce705bb0 | ||
|
|
e6d55e47b1 | ||
|
|
b48533be26 | ||
|
|
7fc517b787 | ||
|
|
8ac996f6b2 | ||
|
|
55557047de | ||
|
|
94e976bae3 | ||
|
|
23cf88856e | ||
|
|
1f7b712dd0 | ||
|
|
90f111fcb1 | ||
|
|
bca27898f7 | ||
|
|
a7efb0044c | ||
|
|
fc0fc57409 | ||
|
|
6aae159698 | ||
|
|
31713c324b | ||
|
|
464b8d35d3 | ||
|
|
e1ae299326 | ||
|
|
253c539f18 | ||
|
|
ff0bb89297 | ||
|
|
c9dd3f8e78 | ||
|
|
e1304b6512 | ||
|
|
b6bfb9148e | ||
|
|
f0f9753c42 | ||
|
|
f137aa79a2 | ||
|
|
39eaa10d85 | ||
|
|
abbb7c798f | ||
|
|
7bb3800490 | ||
|
|
04ebd2a5bd | ||
|
|
be184d8faf | ||
|
|
0c95797242 | ||
|
|
75ae4b6a02 | ||
|
|
547db2fd02 | ||
|
|
f11a29504a | ||
|
|
323ec1ec54 | ||
|
|
09fc871756 | ||
|
|
0344a0c7ff | ||
|
|
7d0e13c591 | ||
|
|
3b7ef6117e | ||
|
|
955c497ba0 | ||
|
|
0bb18c6789 | ||
|
|
07019f54e8 | ||
|
|
2aa3b955f9 | ||
|
|
a2333975f9 | ||
|
|
b8962f4337 | ||
|
|
6cf0601590 | ||
|
|
dd6cbe3a6f | ||
|
|
f5438c4c36 | ||
|
|
ac2118db14 | ||
|
|
82de629339 | ||
|
|
2b8032524d | ||
|
|
261f631318 | ||
|
|
9e59da598e | ||
|
|
367dcc66f2 | ||
|
|
6c99c6a670 | ||
|
|
ae3bc3f246 | ||
|
|
cc9c47254b | ||
|
|
781c4ffebb | ||
|
|
fd93f1a4da | ||
|
|
4f671824dd | ||
|
|
ba0f9bb325 | ||
|
|
c0e52b8432 | ||
|
|
42cf7715d2 | ||
|
|
330c6227bc | ||
|
|
d42293d3f5 | ||
|
|
99c3106835 | ||
|
|
8d16e4d975 | ||
|
|
571ecfc626 | ||
|
|
475e16a85d | ||
|
|
fe7a8ed9ad | ||
|
|
d700b0a948 | ||
|
|
7d9c7f1357 | ||
|
|
a76af739e5 | ||
|
|
a15e4e139b | ||
|
|
e175e050f9 | ||
|
|
95c11b9b46 | ||
|
|
7c22e42b8f | ||
|
|
7f40c54b3f | ||
|
|
76a3a2e04c | ||
|
|
f50a746619 | ||
|
|
40260be07a | ||
|
|
b9f5ec22aa | ||
|
|
b07f9efa9c | ||
|
|
3bf0b38c42 | ||
|
|
567faee3cc | ||
|
|
e22265f5bc | ||
|
|
20ae85f879 | ||
|
|
251eb9c3fc | ||
|
|
bc69e8ff1e | ||
|
|
13732ab96b | ||
|
|
4658852281 | ||
|
|
edb4e54df2 | ||
|
|
3744a6ba3c | ||
|
|
4ec0b9d6c8 | ||
|
|
1fd2fab9a1 | ||
|
|
53352be997 | ||
|
|
dc027cdf40 | ||
|
|
5b3fb8fb3f | ||
|
|
d154bb4b9c | ||
|
|
8b74f4ec25 | ||
|
|
388fc5397c | ||
|
|
454e5f7517 | ||
|
|
9c52e1921f | ||
|
|
6f3aa056a1 | ||
|
|
35303831f7 | ||
|
|
97e8e4fc74 | ||
|
|
30ea1f0dcf | ||
|
|
d3f9f8457a | ||
|
|
3eda482000 | ||
|
|
f548128940 | ||
|
|
90c9ca8708 | ||
|
|
e824e23c8c | ||
|
|
b29125615f | ||
|
|
01e72611f0 | ||
|
|
727569aa32 | ||
|
|
d5d1a463b8 | ||
|
|
46fe3655ab | ||
|
|
47859e5a9b | ||
|
|
758c708766 | ||
|
|
2c5cfcedbc | ||
|
|
bc3a268f66 | ||
|
|
d2fc452c1a | ||
|
|
b8ffd81b7e | ||
|
|
f68c892170 | ||
|
|
f0b21e226e | ||
|
|
58ea2f827a | ||
|
|
089a1d063a | ||
|
|
93c80671e2 | ||
|
|
81f86474b6 | ||
|
|
661eedd29c | ||
|
|
cb3e5420fc | ||
|
|
e36c9382fc | ||
|
|
6b433fa82a | ||
|
|
f13b2a984e | ||
|
|
56161f9a49 | ||
|
|
5fc39b0371 | ||
|
|
cb219b56c4 | ||
|
|
101f5b2a6a | ||
|
|
eda30f53fa | ||
|
|
feefa682b3 | ||
|
|
22ddf8c12a | ||
|
|
d9b7b7aad4 | ||
|
|
2873d8646b | ||
|
|
2260c25dc5 | ||
|
|
e79da27a12 | ||
|
|
2982d0f6bf | ||
|
|
e5068820fa | ||
|
|
eb3a54b19c | ||
|
|
33ada55f12 | ||
|
|
909041adbb | ||
|
|
5f30807ed0 | ||
|
|
4e1d0b1cf0 | ||
|
|
da2ece986a | ||
|
|
f2bed92176 | ||
|
|
7f23e88b69 | ||
|
|
b2ea9e74fe | ||
|
|
dc349947ef | ||
|
|
80e10e1755 | ||
|
|
3707d34c62 | ||
|
|
10833fbe6b | ||
|
|
4ba4e67bc5 | ||
|
|
65bc859918 | ||
|
|
648aa2a742 | ||
|
|
ae0e3b271d | ||
|
|
6e888d9958 | ||
|
|
eeca30e7a6 | ||
|
|
05d434fed3 | ||
|
|
71c02626f4 | ||
|
|
8df0c3a1ef | ||
|
|
ab3a026feb | ||
|
|
16614d1bfb | ||
|
|
f197eb9752 | ||
|
|
70a2bbfaad | ||
|
|
5246638014 | ||
|
|
d6e5d3d1e8 | ||
|
|
94823f85c8 | ||
|
|
2bb08b6877 | ||
|
|
148710f2ed | ||
|
|
18e321b1e6 | ||
|
|
3aec856bac | ||
|
|
3f773cd9c3 | ||
|
|
09a8081e35 | ||
|
|
d19116fd05 | ||
|
|
bae07c8171 | ||
|
|
64c5b40eae | ||
|
|
0c65d5d748 | ||
|
|
031f6ea29a | ||
|
|
43f19ebe87 | ||
|
|
77a4cbd188 | ||
|
|
9407cb9dc4 | ||
|
|
80c952cd6c | ||
|
|
615392216c | ||
|
|
37203e96ab | ||
|
|
10dbce1c70 | ||
|
|
99247ed58d | ||
|
|
714f00ef9d | ||
|
|
9e0b72bc10 | ||
|
|
c678432d25 | ||
|
|
19832dc1e0 | ||
|
|
b3013c42c0 | ||
|
|
cb02dc84f6 |
12
CLAUDE.md
12
CLAUDE.md
@@ -311,13 +311,15 @@ Save bar pattern — use **sticky full-bleed** for long forms (edit document), *
|
||||
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
|
||||
```
|
||||
|
||||
Back link pattern:
|
||||
Back button pattern — use the shared `<BackButton>` component from `$lib/components/BackButton.svelte`:
|
||||
```svelte
|
||||
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
|
||||
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" .../>
|
||||
Zurück zur Übersicht
|
||||
</a>
|
||||
<script lang="ts">
|
||||
import BackButton from '$lib/components/BackButton.svelte';
|
||||
</script>
|
||||
|
||||
<BackButton />
|
||||
```
|
||||
The component calls `history.back()` so the user returns to wherever they came from. Label is always "Zurück" (no contextual suffix — destination is unknown). Touch target ≥ 44px and focus ring are built in. Do not use a static `<a href>` for back navigation.
|
||||
|
||||
Subtle action link (e.g. "new document/person"):
|
||||
```svelte
|
||||
|
||||
@@ -164,12 +164,19 @@
|
||||
<version>3.0.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- PDF rendering for training data export -->
|
||||
<!-- PDF rendering for training data export and thumbnail generation -->
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>pdfbox</artifactId>
|
||||
<version>3.0.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- TIFF decoding plugin for ImageIO (thumbnail generation from scanned TIFFs) -->
|
||||
<dependency>
|
||||
<groupId>com.twelvemonkeys.imageio</groupId>
|
||||
<artifactId>imageio-tiff</artifactId>
|
||||
<version>3.12.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
|
||||
@@ -12,4 +12,9 @@ public interface ActivityFeedRow {
|
||||
UUID getDocumentId();
|
||||
Instant getHappenedAt();
|
||||
boolean isYouMentioned();
|
||||
boolean isYouParticipated();
|
||||
int getCount();
|
||||
Instant getHappenedAtUntil();
|
||||
/** Present only for COMMENT_ADDED and MENTION_CREATED — null otherwise. */
|
||||
UUID getCommentId();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public enum AuditKind {
|
||||
|
||||
/** Payload: none */
|
||||
@@ -24,5 +26,10 @@ public enum AuditKind {
|
||||
COMMENT_ADDED,
|
||||
|
||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||
MENTION_CREATED,
|
||||
MENTION_CREATED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -23,36 +24,92 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
|
||||
|
||||
@Query(value = """
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at))
|
||||
a.kind AS kind,
|
||||
a.actor_id AS actorId,
|
||||
CASE
|
||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
||||
ELSE '?'
|
||||
END AS actorInitials,
|
||||
COALESCE(u.color, '') AS actorColor,
|
||||
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
|
||||
a.document_id AS documentId,
|
||||
a.happened_at AS happened_at,
|
||||
(a.kind = 'MENTION_CREATED'
|
||||
AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned
|
||||
WITH events AS (
|
||||
SELECT
|
||||
a.kind,
|
||||
a.actor_id,
|
||||
a.document_id,
|
||||
a.happened_at,
|
||||
a.payload,
|
||||
LAG(a.happened_at) OVER (
|
||||
PARTITION BY a.actor_id, a.document_id, a.kind
|
||||
ORDER BY a.happened_at
|
||||
) AS prev_happened_at
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_id
|
||||
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED')
|
||||
WHERE a.kind IN (:kinds)
|
||||
AND a.document_id IS NOT NULL
|
||||
ORDER BY a.actor_id, a.document_id, a.kind,
|
||||
date_trunc('hour', a.happened_at), a.happened_at DESC
|
||||
) deduped
|
||||
ORDER BY happened_at DESC
|
||||
),
|
||||
sessions_marked AS (
|
||||
SELECT
|
||||
kind, actor_id, document_id, happened_at, payload,
|
||||
CASE
|
||||
WHEN kind IN ('COMMENT_ADDED','MENTION_CREATED') THEN 1
|
||||
WHEN prev_happened_at IS NULL THEN 1
|
||||
WHEN EXTRACT(EPOCH FROM (happened_at - prev_happened_at)) > 7200 THEN 1
|
||||
ELSE 0
|
||||
END AS is_new_session
|
||||
FROM events
|
||||
),
|
||||
sessions AS (
|
||||
SELECT
|
||||
kind, actor_id, document_id, happened_at, payload,
|
||||
SUM(is_new_session) OVER (
|
||||
PARTITION BY actor_id, document_id, kind
|
||||
ORDER BY happened_at
|
||||
ROWS UNBOUNDED PRECEDING
|
||||
) AS session_id
|
||||
FROM sessions_marked
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT
|
||||
s.kind,
|
||||
s.actor_id,
|
||||
s.document_id,
|
||||
s.session_id,
|
||||
MIN(s.happened_at) AS happened_at,
|
||||
CASE WHEN COUNT(*) > 1 THEN MAX(s.happened_at) ELSE NULL END AS happened_at_until,
|
||||
COUNT(*)::int AS count,
|
||||
BOOL_OR(s.kind = 'MENTION_CREATED'
|
||||
AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned,
|
||||
-- COMMENT_ADDED/MENTION_CREATED always have is_new_session=1, so each group has one row and MIN collapses to that row payload
|
||||
MIN(s.payload::text)::jsonb AS payload
|
||||
FROM sessions s
|
||||
GROUP BY s.kind, s.actor_id, s.document_id, s.session_id
|
||||
)
|
||||
SELECT
|
||||
ag.kind AS kind,
|
||||
ag.actor_id AS actorId,
|
||||
CASE
|
||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
||||
ELSE '?'
|
||||
END AS actorInitials,
|
||||
COALESCE(u.color, '') AS actorColor,
|
||||
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
|
||||
ag.document_id AS documentId,
|
||||
ag.happened_at AS happened_at,
|
||||
ag.you_mentioned AS youMentioned,
|
||||
-- payload->>'commentId' matches notifications.reference_id per AuditKind.COMMENT_ADDED contract
|
||||
EXISTS(
|
||||
SELECT 1 FROM notifications n
|
||||
WHERE n.type = 'REPLY'
|
||||
AND n.recipient_id = CAST(:currentUserId AS uuid)
|
||||
AND n.reference_id = (ag.payload->>'commentId')::uuid
|
||||
) AS youParticipated,
|
||||
ag.count AS count,
|
||||
ag.happened_at_until AS happenedAtUntil,
|
||||
(ag.payload->>'commentId')::uuid AS commentId
|
||||
FROM aggregated ag
|
||||
LEFT JOIN users u ON u.id = ag.actor_id
|
||||
ORDER BY ag.happened_at DESC
|
||||
LIMIT :limit
|
||||
""", nativeQuery = true)
|
||||
List<ActivityFeedRow> findDedupedActivityFeed(
|
||||
List<ActivityFeedRow> findRolledUpActivityFeed(
|
||||
@Param("currentUserId") String currentUserId,
|
||||
@Param("limit") int limit);
|
||||
@Param("limit") int limit,
|
||||
@Param("kinds") Collection<String> kinds);
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
@@ -106,4 +163,38 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
ORDER BY a.document_id, MIN(a.happened_at)
|
||||
""", nativeQuery = true)
|
||||
List<ContributorRow> findContributorsPerDocument(@Param("documentIds") List<UUID> documentIds);
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
ranked.document_id AS documentId,
|
||||
ranked.actorInitials AS actorInitials,
|
||||
ranked.actorColor AS actorColor,
|
||||
ranked.actorName AS actorName
|
||||
FROM (
|
||||
SELECT
|
||||
a.document_id,
|
||||
CASE
|
||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
||||
ELSE '?'
|
||||
END AS actorInitials,
|
||||
COALESCE(u.color, '') AS actorColor,
|
||||
NULLIF(CONCAT_WS(' ', u.first_name, u.last_name), '') AS actorName,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY a.document_id
|
||||
ORDER BY MAX(a.happened_at) DESC
|
||||
) AS rn
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_id
|
||||
WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED')
|
||||
AND a.document_id IN :documentIds
|
||||
AND a.actor_id IS NOT NULL
|
||||
GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color
|
||||
) ranked
|
||||
WHERE ranked.rn <= 4
|
||||
ORDER BY ranked.document_id, ranked.rn
|
||||
""", nativeQuery = true)
|
||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,12 @@ public class AuditLogQueryService {
|
||||
}
|
||||
|
||||
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
|
||||
return queryRepository.findDedupedActivityFeed(currentUserId.toString(), limit);
|
||||
return findActivityFeed(currentUserId, limit, AuditKind.ROLLUP_ELIGIBLE);
|
||||
}
|
||||
|
||||
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit, Set<AuditKind> kinds) {
|
||||
List<String> kindNames = kinds.stream().map(Enum::name).toList();
|
||||
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, kindNames);
|
||||
}
|
||||
|
||||
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {
|
||||
@@ -38,7 +43,15 @@ public class AuditLogQueryService {
|
||||
|
||||
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
|
||||
return toContributorMap(queryRepository.findContributorsPerDocument(documentIds));
|
||||
}
|
||||
|
||||
public Map<UUID, List<ActivityActorDTO>> findRecentContributorsPerDocument(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
||||
}
|
||||
|
||||
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||
for (ContributorRow row : rows) {
|
||||
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())
|
||||
|
||||
@@ -37,4 +37,19 @@ public class AsyncConfig {
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Bean("thumbnailExecutor")
|
||||
public Executor thumbnailExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(1);
|
||||
executor.setMaxPoolSize(2);
|
||||
executor.setQueueCapacity(200);
|
||||
executor.setThreadNamePrefix("Thumbnail-");
|
||||
// CallerRunsPolicy applies back-pressure to quick-upload batches and admin backfill
|
||||
// instead of dropping work (shared taskExecutor uses AbortPolicy). Safe because the
|
||||
// task is dispatched via TransactionSynchronization.afterCommit, which runs on a
|
||||
// post-commit callback thread without active transaction synchronization.
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.MassImportService;
|
||||
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -23,6 +24,7 @@ public class AdminController {
|
||||
private final MassImportService massImportService;
|
||||
private final DocumentService documentService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
private final ThumbnailBackfillService thumbnailBackfillService;
|
||||
|
||||
@PostMapping("/trigger-import")
|
||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||
@@ -47,4 +49,15 @@ public class AdminController {
|
||||
int count = documentService.backfillFileHashes();
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/generate-thumbnails")
|
||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
|
||||
thumbnailBackfillService.runBackfillAsync();
|
||||
return ResponseEntity.accepted().body(thumbnailBackfillService.getStatus());
|
||||
}
|
||||
|
||||
@GetMapping("/thumbnail-status")
|
||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> thumbnailStatus() {
|
||||
return ResponseEntity.ok(thumbnailBackfillService.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,67 +24,6 @@ public class CommentController {
|
||||
private final CommentService commentService;
|
||||
private final UserService userService;
|
||||
|
||||
// ─── General document comments ────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/comments")
|
||||
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
|
||||
return commentService.getCommentsForDocument(documentId);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/comments")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public DocumentComment postDocumentComment(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public DocumentComment replyToDocumentComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||
}
|
||||
|
||||
// ─── Annotation comments ──────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
|
||||
return commentService.getCommentsForAnnotation(annotationId);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public DocumentComment postAnnotationComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID annotationId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public DocumentComment replyToAnnotationComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||
}
|
||||
|
||||
// ─── Block (transcription) comments ────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||
|
||||
@@ -13,10 +13,17 @@ import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -61,6 +68,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@RequestMapping("/api/documents")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Validated
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
@@ -93,6 +101,31 @@ public class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
// --- THUMBNAIL ---
|
||||
@GetMapping("/{id}/thumbnail")
|
||||
public ResponseEntity<InputStreamResource> getDocumentThumbnail(@PathVariable UUID id) {
|
||||
Document doc = documentService.getDocumentById(id);
|
||||
|
||||
if (doc.getThumbnailKey() == null) {
|
||||
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "No thumbnail for document: " + id);
|
||||
}
|
||||
|
||||
try {
|
||||
FileService.S3FileDownload download = fileService.downloadFile(doc.getThumbnailKey());
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.IMAGE_JPEG)
|
||||
// `private` (not `public`) prevents shared caches from serving one user's
|
||||
// thumbnail to another (CWE-525). `immutable` is safe because the URL
|
||||
// carries a ?v=<thumbnailGeneratedAt> cache-buster that changes whenever
|
||||
// the underlying file is replaced.
|
||||
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
|
||||
.body(download.resource());
|
||||
} catch (FileService.StorageFileNotFoundException e) {
|
||||
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND,
|
||||
"Thumbnail missing in storage: " + doc.getThumbnailKey());
|
||||
}
|
||||
}
|
||||
|
||||
// --- METADATA ---
|
||||
@GetMapping("/{id}")
|
||||
public Document getDocument(@PathVariable UUID id) {
|
||||
@@ -161,6 +194,7 @@ public class DocumentController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public QuickUploadResult quickUpload(
|
||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
||||
Authentication authentication) {
|
||||
List<Document> created = new ArrayList<>();
|
||||
List<Document> updated = new ArrayList<>();
|
||||
@@ -170,14 +204,21 @@ public class DocumentController {
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
documentService.validateBatch(files.size(), metadata);
|
||||
|
||||
UUID actorId = requireUserId(authentication);
|
||||
for (MultipartFile file : files) {
|
||||
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
|
||||
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
MultipartFile file = files.get(i);
|
||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
|
||||
DocumentService.StoreResult result = metadata != null
|
||||
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
|
||||
: documentService.storeDocument(file, actorId);
|
||||
if (result.isNew()) {
|
||||
created.add(result.document());
|
||||
} else {
|
||||
@@ -189,15 +230,29 @@ public class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
|
||||
actorId, files.size(), totalBytes, metadata != null,
|
||||
created.size(), updated.size(), errors.size());
|
||||
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete-count")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Map<String, Long> getIncompleteCount() {
|
||||
return Map.of("count", documentService.getIncompleteCount());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<IncompleteDocumentDTO> getIncomplete(
|
||||
@Parameter(description = "Maximum number of results (server caps at 200)")
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
return documentService.findIncompleteDocuments(Math.min(size, 200));
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete/next")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||
return documentService.findNextIncompleteDocument(excludeId)
|
||||
.map(ResponseEntity::ok)
|
||||
@@ -216,14 +271,20 @@ public class DocumentController {
|
||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
|
||||
// @Max on page guards against overflow when pageable.getOffset() is computed
|
||||
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
|
||||
// Hibernate cheerfully turns into an invalid SQL OFFSET.
|
||||
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
|
||||
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||
}
|
||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
@@ -14,5 +14,20 @@ public record ActivityFeedItemDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youParticipated,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count,
|
||||
@Nullable OffsetDateTime happenedAtUntil,
|
||||
@Nullable
|
||||
@Schema(
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
description = "Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
||||
)
|
||||
UUID commentId,
|
||||
@Nullable
|
||||
@Schema(
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
||||
)
|
||||
UUID annotationId
|
||||
) {}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
||||
@@ -9,6 +13,7 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@@ -35,8 +40,12 @@ public class DashboardController {
|
||||
@GetMapping("/activity")
|
||||
public List<ActivityFeedItemDTO> getActivity(
|
||||
Authentication authentication,
|
||||
@RequestParam(defaultValue = "7") int limit) {
|
||||
@RequestParam(defaultValue = "7") int limit,
|
||||
@Parameter(description = "Filter by audit kinds; omit for all rollup-eligible kinds",
|
||||
array = @ArraySchema(schema = @Schema(implementation = AuditKind.class)))
|
||||
@RequestParam(required = false) Set<AuditKind> kinds) {
|
||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
||||
return dashboardService.getActivity(userId, Math.min(limit, 20));
|
||||
Set<AuditKind> effectiveKinds = (kinds == null || kinds.isEmpty()) ? AuditKind.ROLLUP_ELIGIBLE : kinds;
|
||||
return dashboardService.getActivity(userId, Math.min(limit, 40), effectiveKinds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.PulseStatsRow;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.service.CommentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
@@ -32,6 +34,7 @@ public class DashboardService {
|
||||
private final DocumentService documentService;
|
||||
private final TranscriptionService transcriptionService;
|
||||
private final UserService userService;
|
||||
private final CommentService commentService;
|
||||
|
||||
public DashboardResumeDTO getResume(UUID userId) {
|
||||
Optional<UUID> docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId);
|
||||
@@ -79,7 +82,7 @@ public class DashboardService {
|
||||
.toList();
|
||||
|
||||
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
||||
totalBlocks, pct, null, collaborators);
|
||||
totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
|
||||
}
|
||||
|
||||
public DashboardPulseDTO getPulse(UUID userId) {
|
||||
@@ -108,8 +111,8 @@ public class DashboardService {
|
||||
);
|
||||
}
|
||||
|
||||
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit) {
|
||||
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit);
|
||||
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit, Set<AuditKind> kinds) {
|
||||
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit, kinds);
|
||||
|
||||
List<UUID> docIds = rows.stream()
|
||||
.map(ActivityFeedRow::getDocumentId)
|
||||
@@ -125,18 +128,37 @@ public class DashboardService {
|
||||
log.warn("Activity: failed to bulk-load document titles", e);
|
||||
}
|
||||
|
||||
List<UUID> commentIds = rows.stream()
|
||||
.map(ActivityFeedRow::getCommentId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
|
||||
? Map.of()
|
||||
: commentService.findAnnotationIdsByIds(commentIds);
|
||||
|
||||
return rows.stream().map(row -> {
|
||||
ActivityActorDTO actor = row.getActorId() != null
|
||||
? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName())
|
||||
: null;
|
||||
String docTitle = titleCache.getOrDefault(row.getDocumentId(), "");
|
||||
OffsetDateTime happenedAtUntil = row.getHappenedAtUntil() != null
|
||||
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
||||
: null;
|
||||
UUID commentId = row.getCommentId();
|
||||
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
|
||||
return new ActivityFeedItemDTO(
|
||||
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
||||
actor,
|
||||
row.getDocumentId(),
|
||||
docTitle,
|
||||
row.getHappenedAt().atOffset(ZoneOffset.UTC),
|
||||
row.isYouMentioned()
|
||||
row.isYouMentioned(),
|
||||
row.isYouParticipated(),
|
||||
row.getCount(),
|
||||
happenedAtUntil,
|
||||
commentId,
|
||||
annotationId
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
public class DocumentBatchMetadataDTO {
|
||||
private List<String> titles;
|
||||
private UUID senderId;
|
||||
private List<UUID> receiverIds;
|
||||
private LocalDate documentDate;
|
||||
private String location;
|
||||
private List<String> tagNames;
|
||||
private Boolean metadataComplete;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DocumentSearchItem(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
Document document,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
SearchMatchData matchData,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int completionPercentage,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<ActivityActorDTO> contributors
|
||||
) {}
|
||||
@@ -1,35 +1,38 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DocumentSearchResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<Document> documents,
|
||||
List<DocumentSearchItem> items,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
long total,
|
||||
long totalElements,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
Map<UUID, SearchMatchData> matchData
|
||||
int pageNumber,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int pageSize,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int totalPages
|
||||
) {
|
||||
/**
|
||||
* Creates a fully-enriched result from documents and their match overlay data.
|
||||
* Absent map entries (e.g. document deleted between FTS and enrichment) are safe —
|
||||
* the frontend treats a missing entry as "no match data".
|
||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||
*/
|
||||
public static DocumentSearchResult withMatchData(List<Document> documents, Map<UUID, SearchMatchData> matchData) {
|
||||
return new DocumentSearchResult(documents, documents.size(), matchData);
|
||||
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
||||
int size = items.size();
|
||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a result without match data — used for filter-only searches (no text query).
|
||||
* No pagination yet — the full matched set is always returned.
|
||||
* When pagination is added, total must come from a DB COUNT query, not list.size().
|
||||
* Paged factory used by the service when it has a real Pageable + full match count
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||
*/
|
||||
public static DocumentSearchResult of(List<Document> documents) {
|
||||
return withMatchData(documents, Map.of());
|
||||
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
|
||||
int pageSize = pageable.getPageSize();
|
||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record IncompleteDocumentDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime uploadedAt
|
||||
) {}
|
||||
|
||||
@@ -38,6 +38,10 @@ public enum ErrorCode {
|
||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||
IMPORT_ALREADY_RUNNING,
|
||||
|
||||
// --- Thumbnails ---
|
||||
/** A thumbnail backfill is already in progress; only one can run at a time. 409 */
|
||||
THUMBNAIL_BACKFILL_ALREADY_RUNNING,
|
||||
|
||||
// --- Invites ---
|
||||
/** The invite code does not exist. 404 */
|
||||
INVITE_NOT_FOUND,
|
||||
@@ -105,6 +109,8 @@ public enum ErrorCode {
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
VALIDATION_ERROR,
|
||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
||||
BATCH_TOO_LARGE,
|
||||
/** An unexpected server-side error occurred. 500 */
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
@@ -43,6 +46,20 @@ public class Document {
|
||||
@Column(name = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
// S3 key of the generated thumbnail (e.g. "thumbnails/{docId}.jpg"); null until generated
|
||||
@Column(name = "thumbnail_key")
|
||||
private String thumbnailKey;
|
||||
|
||||
@Column(name = "thumbnail_generated_at")
|
||||
private LocalDateTime thumbnailGeneratedAt;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "thumbnail_aspect", length = 16)
|
||||
private ThumbnailAspect thumbnailAspect;
|
||||
|
||||
@Column(name = "page_count")
|
||||
private Integer pageCount;
|
||||
|
||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||
@Column(name = "original_filename", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@@ -117,4 +134,19 @@ public class Document {
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Builder.Default
|
||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||
|
||||
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
||||
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
||||
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
||||
// this URL changes whenever the underlying file does. Dropping the query param
|
||||
// would let browsers serve a stale thumbnail for a year after the file is
|
||||
// replaced, and shared caches could leak one user's thumbnail to another
|
||||
// (CWE-525).
|
||||
@JsonProperty("thumbnailUrl")
|
||||
public String getThumbnailUrl() {
|
||||
if (thumbnailKey == null) return null;
|
||||
String base = "/api/documents/" + id + "/thumbnail";
|
||||
if (thumbnailGeneratedAt == null) return base;
|
||||
return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
public enum ThumbnailAspect {
|
||||
PORTRAIT,
|
||||
LANDSCAPE
|
||||
}
|
||||
@@ -8,10 +8,6 @@ import java.util.UUID;
|
||||
|
||||
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
|
||||
|
||||
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
|
||||
|
||||
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
|
||||
|
||||
List<DocumentComment> findByParentId(UUID parentId);
|
||||
|
||||
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CompletionStatsRow {
|
||||
UUID getDocumentId();
|
||||
int getCompletionPercentage();
|
||||
}
|
||||
@@ -46,6 +46,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||
|
||||
List<Document> findByFilePathIsNotNullAndThumbnailKeyIsNull();
|
||||
|
||||
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
|
||||
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);
|
||||
|
||||
|
||||
@@ -5,12 +5,24 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, UUID> {
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
b.document_id AS documentId,
|
||||
ROUND(COUNT(*) FILTER (WHERE b.reviewed = true) * 100.0 / COUNT(*))::int AS completionPercentage
|
||||
FROM transcription_blocks b
|
||||
WHERE b.document_id IN :documentIds
|
||||
GROUP BY b.document_id
|
||||
""", nativeQuery = true)
|
||||
List<CompletionStatsRow> findCompletionStatsForDocuments(
|
||||
@Param("documentIds") Collection<UUID> documentIds);
|
||||
|
||||
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
|
||||
|
||||
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||
|
||||
@@ -8,10 +8,13 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -26,16 +29,15 @@ public class CommentService {
|
||||
private final UserService userService;
|
||||
private final NotificationService notificationService;
|
||||
private final AuditService auditService;
|
||||
private final TranscriptionService transcriptionService;
|
||||
|
||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||
List<DocumentComment> roots =
|
||||
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
||||
return withRepliesAndMentions(roots);
|
||||
}
|
||||
|
||||
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
||||
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
||||
return withRepliesAndMentions(roots);
|
||||
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
|
||||
if (commentIds == null || commentIds.isEmpty()) return Map.of();
|
||||
Map<UUID, UUID> result = new HashMap<>();
|
||||
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
|
||||
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||
@@ -46,27 +48,11 @@ public class CommentService {
|
||||
@Transactional
|
||||
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
|
||||
List<UUID> mentionedUserIds, AppUser author) {
|
||||
TranscriptionBlock block = transcriptionService.getBlock(documentId, blockId);
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.documentId(documentId)
|
||||
.blockId(blockId)
|
||||
.content(content)
|
||||
.authorId(author.getId())
|
||||
.authorName(resolveAuthorName(author))
|
||||
.build();
|
||||
saveMentions(comment, mentionedUserIds);
|
||||
DocumentComment saved = commentRepository.save(comment);
|
||||
withMentionDTOs(saved);
|
||||
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||
logCommentPosted(author, documentId, saved, mentionedUserIds);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
|
||||
List<UUID> mentionedUserIds, AppUser author) {
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.documentId(documentId)
|
||||
.annotationId(annotationId)
|
||||
.annotationId(block.getAnnotationId())
|
||||
.content(content)
|
||||
.authorId(author.getId())
|
||||
.authorName(resolveAuthorName(author))
|
||||
|
||||
@@ -3,8 +3,12 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
@@ -19,7 +23,9 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -59,6 +65,9 @@ public class DocumentService {
|
||||
private final DocumentVersionService documentVersionService;
|
||||
private final AnnotationService annotationService;
|
||||
private final AuditService auditService;
|
||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
|
||||
public record StoreResult(Document document, boolean isNew) {}
|
||||
|
||||
@@ -120,9 +129,56 @@ public class DocumentService {
|
||||
if (wasPlaceholder) {
|
||||
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
||||
}
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
return new StoreResult(saved, isNew);
|
||||
}
|
||||
|
||||
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
||||
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
||||
if (fileCount > 50) {
|
||||
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
||||
}
|
||||
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StoreResult storeDocumentWithBatchMetadata(
|
||||
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
|
||||
StoreResult base = storeDocument(file, actorId);
|
||||
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
|
||||
return new StoreResult(documentRepository.save(doc), base.isNew());
|
||||
}
|
||||
|
||||
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
|
||||
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
|
||||
doc.setTitle(metadata.getTitles().get(fileIndex));
|
||||
}
|
||||
if (metadata.getSenderId() != null) {
|
||||
doc.setSender(personService.getById(metadata.getSenderId()));
|
||||
}
|
||||
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
|
||||
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
|
||||
}
|
||||
if (metadata.getDocumentDate() != null) {
|
||||
doc.setDocumentDate(metadata.getDocumentDate());
|
||||
}
|
||||
if (metadata.getLocation() != null) {
|
||||
doc.setLocation(metadata.getLocation());
|
||||
}
|
||||
if (metadata.getMetadataComplete() != null) {
|
||||
doc.setMetadataComplete(metadata.getMetadataComplete());
|
||||
}
|
||||
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
|
||||
UUID docId = doc.getId();
|
||||
updateDocumentTags(docId, metadata.getTagNames());
|
||||
doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||
String filename = (file != null && !file.isEmpty())
|
||||
@@ -182,7 +238,8 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// Datei
|
||||
if (file != null && !file.isEmpty()) {
|
||||
boolean fileUploaded = file != null && !file.isEmpty();
|
||||
if (fileUploaded) {
|
||||
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
||||
doc.setFilePath(upload.s3Key());
|
||||
doc.setFileHash(upload.fileHash());
|
||||
@@ -192,6 +249,9 @@ public class DocumentService {
|
||||
|
||||
Document finalDoc = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(finalDoc);
|
||||
if (fileUploaded) {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(finalDoc.getId());
|
||||
}
|
||||
return finalDoc;
|
||||
}
|
||||
|
||||
@@ -244,7 +304,8 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||
if (newFile != null && !newFile.isEmpty()) {
|
||||
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
||||
if (fileReplaced) {
|
||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
doc.setFilePath(upload.s3Key());
|
||||
doc.setFileHash(upload.fileHash());
|
||||
@@ -263,6 +324,10 @@ public class DocumentService {
|
||||
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
|
||||
}
|
||||
|
||||
if (fileReplaced) {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -324,6 +389,7 @@ public class DocumentService {
|
||||
}
|
||||
Document saved = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(saved);
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
if (wasPlaceholder) {
|
||||
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
||||
}
|
||||
@@ -338,13 +404,13 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) {
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
@@ -359,17 +425,18 @@ public class DocumentService {
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
|
||||
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
|
||||
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
||||
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
||||
// rank list. Cost scales linearly with match count — acceptable while documents
|
||||
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
||||
if (sort == DocumentSort.RECEIVER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
List<Document> sorted = sortByFirstReceiver(results, dir);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
if (sort == DocumentSort.SENDER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
List<Document> sorted = sortBySender(results, dir);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
|
||||
// RELEVANCE: default when text present and no explicit sort given
|
||||
@@ -382,12 +449,43 @@ public class DocumentService {
|
||||
.sorted(Comparator.comparingInt(
|
||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
|
||||
Sort springSort = resolveSort(sort, dir);
|
||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
||||
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
|
||||
// Fast path — push sort + paging into the DB and enrich only the returned slice.
|
||||
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
|
||||
Page<Document> page = documentRepository.findAll(spec, pageRequest);
|
||||
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
||||
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
||||
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
||||
return sorted.subList(from, to);
|
||||
}
|
||||
|
||||
private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
|
||||
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
||||
}
|
||||
|
||||
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
|
||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||
|
||||
List<UUID> docIds = colorResolved.stream().map(Document::getId).toList();
|
||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||
|
||||
return colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||
doc,
|
||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
||||
)).toList();
|
||||
}
|
||||
|
||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||
return transcriptionBlockQueryService.getCompletionStats(docIds);
|
||||
}
|
||||
|
||||
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||
@@ -517,7 +615,7 @@ public class DocumentService {
|
||||
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
return documentRepository.findByMetadataCompleteFalse(pageable)
|
||||
.stream()
|
||||
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
|
||||
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle(), doc.getCreatedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,27 @@ public class FileService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a streaming download from S3/MinIO. The caller is responsible for
|
||||
* closing the returned stream — typically via try-with-resources. Preferred
|
||||
* over {@link #downloadFileBytes(String)} for large files (multi-MB PDFs
|
||||
* during thumbnail generation) because it avoids loading the entire file
|
||||
* into heap memory.
|
||||
*/
|
||||
public InputStream downloadFileStream(String s3Key) throws IOException {
|
||||
try {
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(s3Key)
|
||||
.build();
|
||||
return s3Client.getObject(getObjectRequest);
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||
} catch (S3Exception e) {
|
||||
throw new IOException("Failed to open stream from storage: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a presigned URL for downloading an object from S3/MinIO.
|
||||
* Valid for 1 hour — covers multi-page documents on CPU-only OCR hardware
|
||||
|
||||
@@ -59,6 +59,7 @@ public class MassImportService {
|
||||
private final PersonService personService;
|
||||
private final TagService tagService;
|
||||
private final S3Client s3Client;
|
||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
|
||||
@Value("${app.s3.bucket}")
|
||||
private String bucketName;
|
||||
@@ -332,7 +333,10 @@ public class MassImportService {
|
||||
if (tag != null) doc.getTags().add(tag);
|
||||
doc.setMetadataComplete(metadataComplete);
|
||||
|
||||
documentRepository.save(doc);
|
||||
Document saved = documentRepository.save(doc);
|
||||
if (file.isPresent()) {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
}
|
||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* Bridges document upload paths to asynchronous thumbnail generation. Use
|
||||
* {@link #dispatchAfterCommit(UUID)} from inside {@code @Transactional} service methods —
|
||||
* it registers a post-commit hook so the async task only fires when the surrounding
|
||||
* transaction actually commits, and is silently skipped on rollback. Mirrors
|
||||
* {@link org.raddatz.familienarchiv.audit.AuditService#logAfterCommit}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ThumbnailAsyncRunner {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final ThumbnailService thumbnailService;
|
||||
|
||||
/** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */
|
||||
private long generateTimeoutSeconds = 30L;
|
||||
|
||||
/**
|
||||
* Registers a post-commit hook that triggers asynchronous thumbnail generation for the
|
||||
* given document. When no transaction is active the task is dispatched immediately.
|
||||
* Safe to call from inside {@code @Transactional} service methods.
|
||||
*/
|
||||
public void dispatchAfterCommit(UUID documentId) {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
generateAsync(documentId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
generateAsync(documentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs thumbnail generation on the {@code thumbnailExecutor} pool, wrapped in a watchdog
|
||||
* timeout so a hung PDFBox render cannot occupy a pool thread indefinitely. Never throws:
|
||||
* all errors and timeouts are logged and swallowed so upload paths are not affected.
|
||||
*/
|
||||
@Async("thumbnailExecutor")
|
||||
public void generateAsync(UUID documentId) {
|
||||
Optional<Document> docOpt = documentRepository.findById(documentId);
|
||||
if (docOpt.isEmpty()) {
|
||||
log.warn("Thumbnail generation skipped: document not found id={}", documentId);
|
||||
return;
|
||||
}
|
||||
Document doc = docOpt.get();
|
||||
|
||||
ExecutorService timeoutWorker = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "Thumbnail-Render-" + documentId);
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
try {
|
||||
Future<ThumbnailService.Outcome> future = timeoutWorker.submit(
|
||||
() -> thumbnailService.generate(doc));
|
||||
try {
|
||||
future.get(generateTimeoutSeconds, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
log.warn("Thumbnail generation timed out after {}s for doc={}",
|
||||
generateTimeoutSeconds, documentId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail generation errored for doc={} reason={}",
|
||||
documentId, e.getMessage());
|
||||
}
|
||||
} finally {
|
||||
timeoutWorker.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Sequentially regenerates thumbnails for documents that have a file attached but no
|
||||
* thumbnail yet. Runs on the {@code thumbnailExecutor} pool — single-threaded iteration
|
||||
* is intentional: PDFBox + ImageIO are memory-heavy and we cap peak usage by processing
|
||||
* documents one at a time. Only one backfill can run at a time; concurrent starts are
|
||||
* rejected with {@link ErrorCode#THUMBNAIL_BACKFILL_ALREADY_RUNNING}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ThumbnailBackfillService {
|
||||
|
||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||
|
||||
public record BackfillStatus(
|
||||
State state,
|
||||
String message,
|
||||
int total,
|
||||
int processed,
|
||||
int skipped,
|
||||
int failed,
|
||||
LocalDateTime startedAt
|
||||
) {}
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final ThumbnailService thumbnailService;
|
||||
|
||||
private volatile BackfillStatus currentStatus = new BackfillStatus(
|
||||
State.IDLE, "Kein Backfill gestartet.", 0, 0, 0, 0, null);
|
||||
|
||||
public BackfillStatus getStatus() {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
@Async("thumbnailExecutor")
|
||||
public void runBackfillAsync() {
|
||||
if (currentStatus.state() == State.RUNNING) {
|
||||
throw DomainException.conflict(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING,
|
||||
"Thumbnail-Backfill läuft bereits");
|
||||
}
|
||||
|
||||
LocalDateTime startedAt = LocalDateTime.now();
|
||||
List<Document> docs;
|
||||
try {
|
||||
docs = documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull();
|
||||
} catch (Exception e) {
|
||||
currentStatus = new BackfillStatus(State.FAILED,
|
||||
"Backfill fehlgeschlagen: " + e.getMessage(),
|
||||
0, 0, 0, 0, startedAt);
|
||||
log.warn("Thumbnail backfill aborted before starting: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
int total = docs.size();
|
||||
currentStatus = new BackfillStatus(State.RUNNING,
|
||||
"Backfill läuft…", total, 0, 0, 0, startedAt);
|
||||
log.info("Thumbnail backfill started: total={}", total);
|
||||
|
||||
int processed = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
for (Document doc : docs) {
|
||||
ThumbnailService.Outcome outcome;
|
||||
try {
|
||||
outcome = thumbnailService.generate(doc);
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail generation failed for doc={} reason={}",
|
||||
doc.getId(), e.getMessage());
|
||||
outcome = ThumbnailService.Outcome.FAILED;
|
||||
}
|
||||
switch (outcome) {
|
||||
case SUCCESS -> processed++;
|
||||
case SKIPPED -> skipped++;
|
||||
case FAILED -> failed++;
|
||||
}
|
||||
currentStatus = new BackfillStatus(State.RUNNING,
|
||||
"Backfill läuft…", total, processed, skipped, failed, startedAt);
|
||||
}
|
||||
|
||||
long durationMs = Duration.between(startedAt, LocalDateTime.now()).toMillis();
|
||||
log.info("Thumbnail backfill complete: total={} processed={} skipped={} failed={} durationMs={}",
|
||||
total, processed, skipped, failed, durationMs);
|
||||
|
||||
currentStatus = new BackfillStatus(State.DONE,
|
||||
String.format("Fertig: %d erzeugt, %d übersprungen, %d fehlgeschlagen.",
|
||||
processed, skipped, failed),
|
||||
total, processed, skipped, failed, startedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBuffer;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.ThumbnailAspect;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Generates JPEG thumbnail previews for documents (PDF first-page or scaled-down image)
|
||||
* and uploads them to the S3 thumbnails/ prefix. Fire-and-forget from upload paths via
|
||||
* {@link ThumbnailAsyncRunner}; also invoked by {@link ThumbnailBackfillService} for
|
||||
* historical documents. Explicitly does not throw — failures are returned as
|
||||
* {@link Outcome#FAILED} so the backfill can account for them without aborting the run.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ThumbnailService {
|
||||
|
||||
public enum Outcome { SUCCESS, SKIPPED, FAILED }
|
||||
|
||||
private static final int THUMBNAIL_WIDTH = 240;
|
||||
private static final float JPEG_QUALITY = 0.85f;
|
||||
private static final int PDF_RENDER_DPI = 100;
|
||||
// Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should
|
||||
// render in the portrait tile rather than flipping to landscape at 1.01.
|
||||
private static final float LANDSCAPE_THRESHOLD = 1.1f;
|
||||
private static final String PDF_CONTENT_TYPE = "application/pdf";
|
||||
private static final Set<String> IMAGE_CONTENT_TYPES =
|
||||
Set.of("image/jpeg", "image/png", "image/tiff");
|
||||
|
||||
// Deterministic S3 key — `thumbnails/{docId}.jpg`. When a document's file is replaced
|
||||
// the regenerated thumbnail overwrites this same key via PutObject, so we never
|
||||
// orphan old thumbnails. The URL-level cache buster is the `thumbnail_generated_at`
|
||||
// timestamp (see /api/documents/{id}/thumbnail ?v= param).
|
||||
private static final String THUMBNAIL_KEY_PREFIX = "thumbnails/";
|
||||
private static final String THUMBNAIL_KEY_SUFFIX = ".jpg";
|
||||
|
||||
private final FileService fileService;
|
||||
private final S3Client s3Client;
|
||||
private final DocumentRepository documentRepository;
|
||||
|
||||
@Value("${app.s3.bucket}")
|
||||
private String bucketName;
|
||||
|
||||
public ThumbnailService(FileService fileService, S3Client s3Client,
|
||||
DocumentRepository documentRepository) {
|
||||
this.fileService = fileService;
|
||||
this.s3Client = s3Client;
|
||||
this.documentRepository = documentRepository;
|
||||
}
|
||||
|
||||
public Outcome generate(Document doc) {
|
||||
if (doc.getFilePath() == null) {
|
||||
log.debug("Document {} has no filePath, skipping thumbnail", doc.getId());
|
||||
return Outcome.SKIPPED;
|
||||
}
|
||||
String contentType = doc.getContentType();
|
||||
if (contentType == null || !isSupported(contentType)) {
|
||||
log.warn("Document {} has unsupported contentType {}, skipping thumbnail",
|
||||
doc.getId(), contentType);
|
||||
return Outcome.SKIPPED;
|
||||
}
|
||||
|
||||
SourcePreview preview = readSourcePreview(doc, contentType);
|
||||
if (preview == null
|
||||
|| preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) {
|
||||
log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
|
||||
return Outcome.FAILED;
|
||||
}
|
||||
|
||||
byte[] jpeg = encodeThumbnail(preview.image(), doc.getId());
|
||||
if (jpeg == null) return Outcome.FAILED;
|
||||
|
||||
String thumbnailKey = thumbnailKeyFor(doc.getId());
|
||||
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
|
||||
|
||||
ThumbnailResult result = new ThumbnailResult(
|
||||
thumbnailKey, aspectOf(preview.image()), preview.pageCount());
|
||||
return persistThumbnailMetadata(doc, result);
|
||||
}
|
||||
|
||||
private static ThumbnailAspect aspectOf(BufferedImage source) {
|
||||
float ratio = (float) source.getWidth() / source.getHeight();
|
||||
return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT;
|
||||
}
|
||||
|
||||
// First-page image + total page count for the source file. Page count is always
|
||||
// 1 for image uploads; for PDFs it comes straight from PDDocument.
|
||||
private record SourcePreview(BufferedImage image, int pageCount) {}
|
||||
|
||||
// Everything the generate pipeline has already committed to storage and
|
||||
// now wants stamped onto the Document entity in a single save call.
|
||||
private record ThumbnailResult(String key, ThumbnailAspect aspect, int pageCount) {}
|
||||
|
||||
private static String thumbnailKeyFor(UUID documentId) {
|
||||
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
|
||||
}
|
||||
|
||||
private SourcePreview readSourcePreview(Document doc, String contentType) {
|
||||
try {
|
||||
return PDF_CONTENT_TYPE.equals(contentType)
|
||||
? renderPdfFirstPage(doc.getFilePath())
|
||||
: new SourcePreview(readImage(doc.getFilePath()), 1);
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail source read failed for doc={} reason={}",
|
||||
doc.getId(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] encodeThumbnail(BufferedImage source, UUID documentId) {
|
||||
try {
|
||||
BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH);
|
||||
return encodeJpeg(scaled, JPEG_QUALITY);
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail JPEG encoding failed for doc={} reason={}",
|
||||
documentId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean uploadToStorage(String thumbnailKey, byte[] jpeg, UUID documentId) {
|
||||
try {
|
||||
s3Client.putObject(
|
||||
PutObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(thumbnailKey)
|
||||
.contentType("image/jpeg")
|
||||
.build(),
|
||||
RequestBody.fromBytes(jpeg));
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("Thumbnail upload failed for doc={} key={} reason={}",
|
||||
documentId, thumbnailKey, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) {
|
||||
try {
|
||||
doc.setThumbnailKey(result.key());
|
||||
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
||||
doc.setThumbnailAspect(result.aspect());
|
||||
doc.setPageCount(result.pageCount());
|
||||
documentRepository.save(doc);
|
||||
return Outcome.SUCCESS;
|
||||
} catch (Exception e) {
|
||||
// Thumbnail is already in S3 but the entity update failed. Because the S3
|
||||
// key is deterministic (thumbnails/{docId}.jpg), the next successful run
|
||||
// — either a re-upload of this document or the admin backfill — will
|
||||
// overwrite it cleanly. Logging distinctly so an operator tracking
|
||||
// backfill totals can spot the database-side issue.
|
||||
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
|
||||
doc.getId(), result.key(), e.getMessage());
|
||||
return Outcome.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSupported(String contentType) {
|
||||
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
|
||||
}
|
||||
|
||||
private SourcePreview renderPdfFirstPage(String s3Key) throws IOException {
|
||||
try (InputStream in = fileService.downloadFileStream(s3Key);
|
||||
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
|
||||
PDFRenderer renderer = new PDFRenderer(pdf);
|
||||
BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
|
||||
return new SourcePreview(image, pdf.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage readImage(String s3Key) throws IOException {
|
||||
try (InputStream in = fileService.downloadFileStream(s3Key)) {
|
||||
BufferedImage img = ImageIO.read(in);
|
||||
if (img == null) {
|
||||
throw new IOException("No ImageIO reader available for " + s3Key);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage scaleToWidth(BufferedImage source, int targetWidth) {
|
||||
int sourceWidth = source.getWidth();
|
||||
int sourceHeight = source.getHeight();
|
||||
int targetHeight = Math.max(1, Math.round((float) targetWidth * sourceHeight / sourceWidth));
|
||||
BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = scaled.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
|
||||
g.dispose();
|
||||
return scaled;
|
||||
}
|
||||
|
||||
private byte[] encodeJpeg(BufferedImage image, float quality) throws IOException {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
param.setCompressionQuality(quality);
|
||||
try (ImageOutputStream out = ImageIO.createImageOutputStream(bos)) {
|
||||
writer.setOutput(out);
|
||||
writer.write(null, new IIOImage(image, null, null), param);
|
||||
} finally {
|
||||
writer.dispose();
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.repository.CompletionStatsRow;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TranscriptionBlockQueryService {
|
||||
|
||||
private final TranscriptionBlockRepository blockRepository;
|
||||
|
||||
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
Map<UUID, Integer> result = new HashMap<>();
|
||||
for (CompletionStatsRow row : blockRepository.findCompletionStatsForDocuments(documentIds)) {
|
||||
result.put(row.getDocumentId(), row.getCompletionPercentage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ spring:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
|
||||
file-size-threshold: 2KB
|
||||
|
||||
mail:
|
||||
host: ${MAIL_HOST:}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_reviewed
|
||||
ON transcription_blocks (document_id, reviewed);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Partial covering index for the session-style activity feed rollup (#285).
|
||||
-- Matches the WHERE clause of AuditLogQueryRepository.findRolledUpActivityFeed
|
||||
-- exactly. DESC on happened_at supports the outer ORDER BY without a sort step.
|
||||
CREATE INDEX idx_audit_log_rollup
|
||||
ON audit_log (actor_id, document_id, kind, happened_at DESC)
|
||||
WHERE kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED',
|
||||
'BLOCK_REVIEWED','COMMENT_ADDED','MENTION_CREATED');
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Backfill COMMENT_ADDED and MENTION_CREATED audit events for comments
|
||||
-- created before audit logging was added in commit 428c63a2.
|
||||
-- Without these rows the Chronik activity feed (which reads exclusively from
|
||||
-- audit_log) cannot surface pre-existing comments in "Für dich" or "Alle".
|
||||
|
||||
INSERT INTO audit_log (id, happened_at, actor_id, kind, document_id, payload)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
c.created_at,
|
||||
c.author_id,
|
||||
'COMMENT_ADDED',
|
||||
c.document_id,
|
||||
jsonb_build_object('commentId', c.id::text)
|
||||
FROM document_comments c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM audit_log a
|
||||
WHERE a.kind = 'COMMENT_ADDED'
|
||||
AND a.payload->>'commentId' = c.id::text
|
||||
);
|
||||
|
||||
INSERT INTO audit_log (id, happened_at, actor_id, kind, document_id, payload)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
c.created_at,
|
||||
c.author_id,
|
||||
'MENTION_CREATED',
|
||||
c.document_id,
|
||||
jsonb_build_object(
|
||||
'commentId', c.id::text,
|
||||
'mentionedUserId', m.user_id::text
|
||||
)
|
||||
FROM comment_mentions m
|
||||
JOIN document_comments c ON c.id = m.comment_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM audit_log a
|
||||
WHERE a.kind = 'MENTION_CREATED'
|
||||
AND a.payload->>'commentId' = c.id::text
|
||||
AND a.payload->>'mentionedUserId' = m.user_id::text
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Backfill annotation_id on block comments and their notifications.
|
||||
--
|
||||
-- Before the upstream fix, CommentService.postBlockComment did not set
|
||||
-- DocumentComment.annotationId, so block comments were stored with
|
||||
-- annotation_id = NULL and every notification built from them inherited
|
||||
-- that NULL (see NotificationService.notifyMentions/notifyReply).
|
||||
--
|
||||
-- The frontend deep-link flow needs annotationId in the URL query string
|
||||
-- to open the correct annotation panel and scroll to the comment.
|
||||
-- Without this backfill, previously issued notifications would still
|
||||
-- carry annotation_id = NULL even after the code fix lands.
|
||||
|
||||
UPDATE document_comments dc
|
||||
SET annotation_id = tb.annotation_id
|
||||
FROM transcription_blocks tb
|
||||
WHERE dc.block_id = tb.id
|
||||
AND dc.annotation_id IS NULL;
|
||||
|
||||
UPDATE notifications n
|
||||
SET annotation_id = dc.annotation_id
|
||||
FROM document_comments dc
|
||||
WHERE n.reference_id = dc.id
|
||||
AND n.annotation_id IS NULL
|
||||
AND dc.annotation_id IS NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN thumbnail_key VARCHAR(255),
|
||||
ADD COLUMN thumbnail_generated_at TIMESTAMP;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Adds two nullable metadata columns populated by ThumbnailService when it
|
||||
-- generates the JPEG preview: thumbnail_aspect (PORTRAIT | LANDSCAPE, from the
|
||||
-- source image w/h ratio with threshold 1.1) and page_count (from PDDocument
|
||||
-- for PDFs, 1 for image uploads). Both are null until the existing admin
|
||||
-- backfill endpoint (/api/admin/generate-thumbnails) reruns the service.
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN thumbnail_aspect VARCHAR(16),
|
||||
ADD COLUMN page_count INTEGER;
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuditLogQueryServiceTest {
|
||||
|
||||
@Mock AuditLogQueryRepository queryRepository;
|
||||
@InjectMocks AuditLogQueryService auditLogQueryService;
|
||||
|
||||
@Test
|
||||
void findActivityFeed_withKinds_passesKindNamesToRepository() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
Set<AuditKind> kinds = Set.of(AuditKind.FILE_UPLOADED);
|
||||
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
List<ActivityFeedRow> result = auditLogQueryService.findActivityFeed(userId, 10, kinds);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||
eq(List.of("FILE_UPLOADED")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findActivityFeed_twoArg_defaultsToAllRollupEligibleKinds() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
auditLogQueryService.findActivityFeed(userId, 10);
|
||||
|
||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.MassImportService;
|
||||
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
@@ -16,10 +17,13 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
@@ -33,6 +37,7 @@ class AdminControllerTest {
|
||||
@MockitoBean MassImportService massImportService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean DocumentVersionService documentVersionService;
|
||||
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
@Test
|
||||
@@ -83,4 +88,57 @@ class AdminControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── POST /api/admin/generate-thumbnails ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void generateThumbnails_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void generateThumbnails_returns202_withStatus_whenAdmin() throws Exception {
|
||||
ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus(
|
||||
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
|
||||
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.state").value("RUNNING"))
|
||||
.andExpect(jsonPath("$.total").value(10));
|
||||
|
||||
verify(thumbnailBackfillService).runBackfillAsync();
|
||||
}
|
||||
|
||||
// ─── GET /api/admin/thumbnail-status ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void thumbnailStatus_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/thumbnail-status"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void thumbnailStatus_returns200_withCurrentStatus_whenAdmin() throws Exception {
|
||||
ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus(
|
||||
ThumbnailBackfillService.State.DONE, "Fertig: 5 erzeugt, 0 übersprungen, 0 fehlgeschlagen.",
|
||||
5, 5, 0, 0, LocalDateTime.now());
|
||||
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/thumbnail-status"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.state").value("DONE"))
|
||||
.andExpect(jsonPath("$.processed").value(5))
|
||||
.andExpect(jsonPath("$.total").value(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,246 +40,8 @@ class CommentControllerTest {
|
||||
|
||||
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
||||
private static final UUID DOC_ID = UUID.randomUUID();
|
||||
private static final UUID ANN_ID = UUID.randomUUID();
|
||||
private static final UUID COMMENT_ID = UUID.randomUUID();
|
||||
|
||||
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
|
||||
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
|
||||
|
||||
@Test
|
||||
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.content").value("Test comment"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void postDocumentComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
|
||||
|
||||
@Test
|
||||
void replyToComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||
.authorName("Anna").content("Test comment").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void replyToComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||
.authorName("Anna").content("Test comment").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
|
||||
|
||||
@Test
|
||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void editComment_returns200_whenHasPermission() throws Exception {
|
||||
DocumentComment updated = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
|
||||
|
||||
@Test
|
||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
|
||||
|
||||
@Test
|
||||
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
|
||||
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void editComment_returns200_whenHasWriteAllPermission() throws Exception {
|
||||
DocumentComment updated = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postAnnotationComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||
.authorName("Hans").content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void postAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||
.authorName("Hans").content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToAnnotationComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void replyToAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── resolveUser — exception branch ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception {
|
||||
// findByEmail throws → catch block in resolveUser → author null, saves anyway
|
||||
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── Block comment endpoints ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -305,4 +67,138 @@ class CommentControllerTest {
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void postBlockComment_stillSucceeds_whenUserServiceThrows() throws Exception {
|
||||
// findByEmail throws → catch block in resolveUser → author null, saves anyway
|
||||
UUID blockId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── Block reply endpoints ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID)
|
||||
.authorName("Anna").content("Reply").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void replyToBlockComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID)
|
||||
.authorName("Anna").content("Reply").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── PATCH /api/documents/{documentId}/comments/{commentId} (shared edit) ──
|
||||
|
||||
@Test
|
||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void editComment_returns200_whenHasPermission() throws Exception {
|
||||
DocumentComment updated = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void editComment_returns200_whenHasWriteAllPermission() throws Exception {
|
||||
DocumentComment updated = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{documentId}/comments/{commentId} (shared) ────
|
||||
|
||||
@Test
|
||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
@@ -25,10 +27,12 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -40,6 +44,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -66,7 +71,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
@@ -76,13 +81,13 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_withStatusParam_passesItToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any());
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -109,18 +114,18 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseContainsTotalCount() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(0))
|
||||
.andExpect(jsonPath("$.documents").isArray());
|
||||
.andExpect(jsonPath("$.totalElements").value(0))
|
||||
.andExpect(jsonPath("$.items").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseBodyContainsMatchDataKey() throws Exception {
|
||||
void search_responseBodyItemsContainMatchData() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(docId)
|
||||
@@ -128,18 +133,82 @@ class DocumentControllerTest {
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData(
|
||||
var matchData = new SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.matchData").isMap())
|
||||
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
|
||||
.andExpect(jsonPath("$.items").isArray())
|
||||
.andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet")
|
||||
.value("Er schrieb einen langen Brief"));
|
||||
}
|
||||
|
||||
// ─── /api/documents/search pagination ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseExposesPagingFields() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.pageNumber").exists())
|
||||
.andExpect(jsonPath("$.pageSize").exists())
|
||||
.andExpect(jsonPath("$.totalPages").exists())
|
||||
.andExpect(jsonPath("$.totalElements").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenSizeExceedsMax() throws Exception {
|
||||
// Locks @Validated on the controller — removing it silently reopens the
|
||||
// DoS window where a client could request all 1500 docs + enrichment.
|
||||
mockMvc.perform(get("/api/documents/search").param("size", "101"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenSizeBelowMin() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search").param("size", "0"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenPageNegative() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenPageAboveMax() throws Exception {
|
||||
// Guards against page * size overflow into negative SQL OFFSET
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_passesPageRequestToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
|
||||
org.springframework.data.domain.Pageable pageable = captor.getValue();
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
||||
}
|
||||
|
||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -359,6 +428,62 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/thumbnail ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentThumbnail_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/thumbnail"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentThumbnail_returns404_whenDocHasNoThumbnail() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf").build();
|
||||
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentThumbnail_returns200_withPrivateCacheHeader() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg").build();
|
||||
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
|
||||
when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
|
||||
.thenReturn(new FileService.S3FileDownload(
|
||||
new org.springframework.core.io.InputStreamResource(stream), "image/jpeg"));
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Type", "image/jpeg"))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("private")))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("public"))))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("immutable")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentThumbnail_returns404_whenStorageObjectMissing() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg").build();
|
||||
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||
when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
|
||||
.thenThrow(new FileService.StorageFileNotFoundException("not found"));
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/quick-upload — null/empty files ─────────────────
|
||||
|
||||
@Test
|
||||
@@ -380,7 +505,7 @@ class DocumentControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getIncompleteCount_returns200_withCount() throws Exception {
|
||||
when(documentService.getIncompleteCount()).thenReturn(3L);
|
||||
|
||||
@@ -389,14 +514,52 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete (removed — superseded by dashboard) ────
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getIncompleteCount_returns403_forReaderOnly() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_endpointRemoved() throws Exception {
|
||||
// The path hits /{id} and fails UUID conversion — not a 200 anymore
|
||||
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().is4xxClientError());
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = {"WRITE_ALL"})
|
||||
void getIncomplete_returns200_forWriter_withDTOList() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
java.time.LocalDateTime uploadedAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
|
||||
var dto = new org.raddatz.familienarchiv.dto.IncompleteDocumentDTO(id, "Unvollständig", uploadedAt);
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Unvollständig"))
|
||||
.andExpect(jsonPath("$[0].uploadedAt").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getIncomplete_returns403_forReaderOnly() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getIncomplete_capsSizeAt200() throws Exception {
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete").param("size", "9999"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIncompleteDocuments(200);
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||
@@ -409,7 +572,7 @@ class DocumentControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getNextIncomplete_returns200_whenNextExists() throws Exception {
|
||||
UUID excludeId = UUID.randomUUID();
|
||||
Document next = Document.builder()
|
||||
@@ -423,7 +586,15 @@ class DocumentControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getNextIncomplete_returns403_forReaderOnly() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||
.param("excludeId", UUID.randomUUID().toString()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
|
||||
UUID excludeId = UUID.randomUUID();
|
||||
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
|
||||
@@ -597,4 +768,165 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||
|
||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
|
||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
|
||||
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc1, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc2, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc3, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f3 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created.length()").value(3))
|
||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
|
||||
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docA, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docB, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docC, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f3 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
|
||||
.when(documentService).validateBatch(eq(2), any());
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||
.containsExactly("Briefwechsel", "Krieg");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
|
||||
.when(documentService).validateBatch(eq(51), any());
|
||||
|
||||
var builder = multipart("/api/documents/quick-upload");
|
||||
for (int i = 0; i < 51; i++) {
|
||||
builder.file(new org.springframework.mock.web.MockMultipartFile(
|
||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||
}
|
||||
|
||||
mockMvc.perform(builder)
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
|
||||
import org.raddatz.familienarchiv.audit.ContributorRow;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class AuditLogQueryRepositoryContributorsTest {
|
||||
|
||||
static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
static final UUID USER_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000001");
|
||||
static final UUID USER_B = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000002");
|
||||
static final UUID USER_C = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000003");
|
||||
static final UUID USER_D = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000004");
|
||||
static final UUID USER_E = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000005");
|
||||
|
||||
@Autowired AuditLogQueryRepository auditLogQueryRepository;
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#f00')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
||||
})
|
||||
void findRecentContributors_returns_contributor_with_initials_and_color() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(rows.get(0).getActorInitials()).isEqualTo("AM");
|
||||
assertThat(rows.get(0).getActorColor()).isEqualTo("#f00");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#aaa')",
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000002', true, 'b@test.com', 'pw', 'Ben', 'Wolf', '#bbb')",
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000003', true, 'c@test.com', 'pw', 'Clara', 'Zorn', '#ccc')",
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000004', true, 'd@test.com', 'pw', 'Dirk', 'Ott', '#ddd')",
|
||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000005', true, 'e@test.com', 'pw', 'Eva', 'Kern', '#eee')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '5 hours')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000002', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '4 hours')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000003', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '3 hours')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000004', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '2 hours')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000005', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '1 hour')"
|
||||
})
|
||||
void findRecentContributors_limits_to_4_most_recent() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).hasSize(4);
|
||||
// Most recent first: E, D, C, B (A is 5th, excluded)
|
||||
assertThat(rows.get(0).getActorInitials()).isEqualTo("EK");
|
||||
assertThat(rows.get(1).getActorInitials()).isEqualTo("DO");
|
||||
assertThat(rows.get(2).getActorInitials()).isEqualTo("CZ");
|
||||
assertThat(rows.get(3).getActorInitials()).isEqualTo("BW");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')"
|
||||
})
|
||||
void findRecentContributors_returns_empty_when_no_audit_entries() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
|
||||
// Deleted user: ON DELETE SET NULL makes actor_id NULL — query excludes these rows
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('TEXT_SAVED', NULL, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
||||
})
|
||||
void findRecentContributors_excludes_entries_from_deleted_users() {
|
||||
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
|
||||
|
||||
assertThat(rows).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -49,13 +49,16 @@ class AuditLogQueryRepositoryIntegrationTest {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')"
|
||||
})
|
||||
void findDedupedActivityFeed_returnsAnnotationEntry() {
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10);
|
||||
void findRolledUpActivityFeed_returnsAnnotationEntry() {
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10,
|
||||
List.of("TEXT_SAVED","FILE_UPLOADED","ANNOTATION_CREATED","BLOCK_REVIEWED","COMMENT_ADDED","MENTION_CREATED"));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED");
|
||||
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(rows.get(0).getHappenedAt()).isNotNull();
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(1);
|
||||
assertThat(rows.get(0).getHappenedAtUntil()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
@Transactional
|
||||
class AuditLogQueryRepositoryRolledUpTest {
|
||||
|
||||
static final UUID USER_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
static final UUID OTHER_USER_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
static final UUID DOC_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||
static final UUID OTHER_DOC_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
|
||||
static final List<String> ALL_ELIGIBLE_KINDS =
|
||||
AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList();
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired AuditLogQueryRepository auditLogQueryRepository;
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
private NamedParameterJdbcTemplate named() {
|
||||
return new NamedParameterJdbcTemplate(jdbcTemplate);
|
||||
}
|
||||
|
||||
private void insertUserAndDocs() {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
|
||||
USER_ID, "rollup-" + USER_ID + "@test.com");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
|
||||
OTHER_USER_ID, "rollup-" + OTHER_USER_ID + "@test.com");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief A', 'a.pdf', 'PLACEHOLDER')",
|
||||
DOC_ID);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief B', 'b.pdf', 'PLACEHOLDER')",
|
||||
OTHER_DOC_ID);
|
||||
}
|
||||
|
||||
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt) {
|
||||
insertAuditEvent(actorId, docId, kind, happenedAt, Map.of());
|
||||
}
|
||||
|
||||
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt, Map<String, String> payload) {
|
||||
String payloadJson;
|
||||
try {
|
||||
payloadJson = payload.isEmpty() ? null : MAPPER.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
MapSqlParameterSource params = new MapSqlParameterSource()
|
||||
.addValue("kind", kind)
|
||||
.addValue("actor", actorId)
|
||||
.addValue("doc", docId)
|
||||
.addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC))
|
||||
.addValue("payload", payloadJson, java.sql.Types.OTHER);
|
||||
named().update(
|
||||
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at, payload) "
|
||||
+ "VALUES (:kind, :actor, :doc, :t, :payload::jsonb)",
|
||||
params);
|
||||
}
|
||||
|
||||
private void insertReplyNotification(UUID recipientId, UUID docId, UUID commentId) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO notifications (recipient_id, type, document_id, reference_id) VALUES (?, 'REPLY', ?, ?)",
|
||||
recipientId, docId, commentId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_combines_same_actor_same_doc_within_2h() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T09:00:00Z");
|
||||
for (int i = 0; i < 20; i++) {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
|
||||
}
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
ActivityFeedRow row = rows.get(0);
|
||||
assertThat(row.getKind()).isEqualTo("TEXT_SAVED");
|
||||
assertThat(row.getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(row.getCount()).isEqualTo(20);
|
||||
assertThat(row.getHappenedAt()).isEqualTo(base);
|
||||
assertThat(row.getHappenedAtUntil()).isEqualTo(base.plusSeconds(19 * 480L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_splits_at_2h_boundary() {
|
||||
insertUserAndDocs();
|
||||
Instant sessionOneStart = Instant.parse("2026-04-20T08:00:00Z");
|
||||
Instant sessionOneLast = sessionOneStart.plusSeconds(600);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneStart);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneLast);
|
||||
Instant sessionTwoStart = sessionOneLast.plusSeconds(2L * 60L * 60L + 60L);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart.plusSeconds(300));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(2);
|
||||
assertThat(rows.get(0).getHappenedAt()).isEqualTo(sessionTwoStart);
|
||||
assertThat(rows.get(1).getCount()).isEqualTo(2);
|
||||
assertThat(rows.get(1).getHappenedAt()).isEqualTo(sessionOneStart);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_has_no_hard_cap_on_long_session() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T06:00:00Z");
|
||||
for (int i = 0; i < 30; i++) {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(i * 60L * 30L));
|
||||
}
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(30);
|
||||
assertThat(rows.get(0).getHappenedAt()).isEqualTo(base);
|
||||
assertThat(rows.get(0).getHappenedAtUntil()).isEqualTo(base.plusSeconds(29 * 60L * 30L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_never_rolls_up_COMMENT_ADDED_or_MENTION_CREATED() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(120));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(3);
|
||||
assertThat(rows).allSatisfy(r -> {
|
||||
assertThat(r.getKind()).isEqualTo("COMMENT_ADDED");
|
||||
assertThat(r.getCount()).isEqualTo(1);
|
||||
assertThat(r.getHappenedAtUntil()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_excludes_non_eligible_kinds() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T12:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "STATUS_CHANGED", base);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "METADATA_UPDATED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(120));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_exposes_count_and_happenedAtUntil_on_singletons_and_rollups() {
|
||||
insertUserAndDocs();
|
||||
Instant rollupStart = Instant.parse("2026-04-20T11:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(300));
|
||||
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(900));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows).anySatisfy(r -> {
|
||||
assertThat(r.getDocumentId()).isEqualTo(DOC_ID);
|
||||
assertThat(r.getCount()).isEqualTo(2);
|
||||
assertThat(r.getHappenedAt()).isEqualTo(rollupStart);
|
||||
assertThat(r.getHappenedAtUntil()).isEqualTo(rollupStart.plusSeconds(300));
|
||||
});
|
||||
assertThat(rows).anySatisfy(r -> {
|
||||
assertThat(r.getDocumentId()).isEqualTo(OTHER_DOC_ID);
|
||||
assertThat(r.getCount()).isEqualTo(1);
|
||||
assertThat(r.getHappenedAt()).isEqualTo(rollupStart.plusSeconds(900));
|
||||
assertThat(r.getHappenedAtUntil()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void youParticipated_is_true_when_user_has_reply_notification_for_comment() {
|
||||
insertUserAndDocs();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
insertReplyNotification(USER_ID, DOC_ID, commentId);
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).anySatisfy(r ->
|
||||
assertThat(r.isYouParticipated()).isTrue()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void youParticipated_is_false_for_comment_with_no_reply_notification() {
|
||||
insertUserAndDocs();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).allSatisfy(r ->
|
||||
assertThat(r.isYouParticipated()).isFalse()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void youParticipated_is_false_when_comment_added_has_no_commentId_in_payload() {
|
||||
insertUserAndDocs();
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of());
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).allSatisfy(r ->
|
||||
assertThat(r.isYouParticipated()).isFalse()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void youParticipated_is_false_when_reply_notification_belongs_to_other_user() {
|
||||
insertUserAndDocs();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
insertReplyNotification(OTHER_USER_ID, DOC_ID, commentId);
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).allSatisfy(r ->
|
||||
assertThat(r.isYouParticipated()).isFalse()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void youMentioned_is_true_when_mention_created_payload_matches_current_user() {
|
||||
insertUserAndDocs();
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", USER_ID.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).anySatisfy(r ->
|
||||
assertThat(r.isYouMentioned()).isTrue()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_exposes_commentId_for_COMMENT_ADDED_events() {
|
||||
insertUserAndDocs();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_exposes_commentId_for_MENTION_CREATED_events() {
|
||||
insertUserAndDocs();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"),
|
||||
Map.of("commentId", commentId.toString(), "mentionedUserId", USER_ID.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_commentId_is_null_for_non_comment_kinds() {
|
||||
insertUserAndDocs();
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("blockId", "ccc", "pageNumber", "1"));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCommentId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void youMentioned_is_false_when_mention_created_payload_targets_different_user() {
|
||||
insertUserAndDocs();
|
||||
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED",
|
||||
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", OTHER_USER_ID.toString()));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
|
||||
|
||||
assertThat(rows).allSatisfy(r ->
|
||||
assertThat(r.isYouMentioned()).isFalse()
|
||||
);
|
||||
}
|
||||
|
||||
// ─── kinds filter ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_with_single_kind_returns_only_that_kind() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40, List.of("FILE_UPLOADED"));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("FILE_UPLOADED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_with_multiple_kinds_returns_union() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
|
||||
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40, List.of("TEXT_SAVED", "FILE_UPLOADED"));
|
||||
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows).extracting(ActivityFeedRow::getKind)
|
||||
.containsExactlyInAnyOrder("TEXT_SAVED", "FILE_UPLOADED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_with_default_returns_all_six_eligible_kinds() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "BLOCK_REVIEWED", base.plusSeconds(7300));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(7360));
|
||||
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED", base.plusSeconds(7420));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40,
|
||||
List.of("TEXT_SAVED", "FILE_UPLOADED", "ANNOTATION_CREATED",
|
||||
"BLOCK_REVIEWED", "COMMENT_ADDED", "MENTION_CREATED"));
|
||||
|
||||
assertThat(rows).hasSize(6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_excludes_rows_not_in_filter_set() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T10:00:00Z");
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
|
||||
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40, List.of("TEXT_SAVED"));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledUpFeed_rollup_still_works_when_kind_set_is_filtered_to_single_rollable_kind() {
|
||||
insertUserAndDocs();
|
||||
Instant base = Instant.parse("2026-04-20T09:00:00Z");
|
||||
for (int i = 0; i < 10; i++) {
|
||||
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
|
||||
}
|
||||
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(20));
|
||||
|
||||
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
|
||||
USER_ID.toString(), 40, List.of("TEXT_SAVED"));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
|
||||
assertThat(rows.get(0).getCount()).isEqualTo(10);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
@@ -15,10 +16,12 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
@@ -134,10 +137,79 @@ class DashboardControllerTest {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_clamps_limit_to_40() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity").param("limit", "9999"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(dashboardService).getActivity(any(UUID.class), org.mockito.ArgumentMatchers.eq(40), any());
|
||||
}
|
||||
|
||||
// ─── GET /api/dashboard/activity — kinds param ───────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_parsesKinds_fromCsvQueryParam() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity")
|
||||
.param("kinds", "FILE_UPLOADED", "TEXT_SAVED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.FILE_UPLOADED, AuditKind.TEXT_SAVED)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_returns400_forUnknownKindValue() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/activity").param("kinds", "INVALID_KIND"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_defaults_to_rollupEligible_whenKindsAbsent() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.ROLLUP_ELIGIBLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void activity_treats_single_valid_kind_as_filter() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(
|
||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
||||
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/dashboard/activity").param("kinds", "COMMENT_ADDED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.COMMENT_ADDED)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,30 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.PulseStatsRow;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.service.CommentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -33,9 +42,35 @@ class DashboardServiceTest {
|
||||
@Mock DocumentService documentService;
|
||||
@Mock TranscriptionService transcriptionService;
|
||||
@Mock UserService userService;
|
||||
@Mock CommentService commentService;
|
||||
|
||||
@InjectMocks DashboardService dashboardService;
|
||||
|
||||
// ─── getResume wires thumbnailUrl from Document ───────────────────────────
|
||||
|
||||
@Test
|
||||
void getResume_populatesThumbnailUrl_fromDocument() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.fromString("12345678-aaaa-bbbb-cccc-1234567890ab");
|
||||
|
||||
Document doc = Document.builder()
|
||||
.id(docId).title("Brief").originalFilename("brief.pdf")
|
||||
.thumbnailKey("thumbnails/" + docId + ".jpg")
|
||||
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
|
||||
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
when(transcriptionService.listBlocks(docId)).thenReturn(List.of());
|
||||
|
||||
DashboardResumeDTO result = dashboardService.getResume(userId);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.thumbnailUrl()).isEqualTo(doc.getThumbnailUrl());
|
||||
assertThat(result.thumbnailUrl()).startsWith("/api/documents/" + docId + "/thumbnail?v=");
|
||||
}
|
||||
|
||||
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -79,7 +114,7 @@ class DashboardServiceTest {
|
||||
UUID docId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "ANNOTATION_CREATED");
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row, row));
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row, row));
|
||||
|
||||
Document doc = Document.builder()
|
||||
.id(docId).title("Familienbrief").originalFilename("f.pdf")
|
||||
@@ -87,14 +122,101 @@ class DashboardServiceTest {
|
||||
.build();
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(doc));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items).hasSize(2);
|
||||
assertThat(items.get(0).documentTitle()).isEqualTo("Familienbrief");
|
||||
verify(documentService, never()).getDocumentById(docId);
|
||||
}
|
||||
|
||||
// ─── getActivity comment/annotation enrichment ────────────────────────────
|
||||
|
||||
@Test
|
||||
void getActivity_populatesCommentId_forCommentEvents() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).commentId()).isEqualTo(commentId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActivity_populatesAnnotationId_viaCommentService() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, annotationId));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).annotationId()).isEqualTo(annotationId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActivity_leavesBothNull_forNonCommentKinds() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).commentId()).isNull();
|
||||
assertThat(items.get(0).annotationId()).isNull();
|
||||
verify(commentService, never()).findAnnotationIdsByIds(anyList());
|
||||
}
|
||||
|
||||
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
|
||||
|
||||
@Test
|
||||
void pulse_uses_all_rollup_eligible_kinds_never_calls_kinds_filtered_overload() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
PulseStatsRow stats = new PulseStatsRow() {
|
||||
public long getPages() { return 0; }
|
||||
public long getAnnotated() { return 0; }
|
||||
public long getTranscribed() { return 0; }
|
||||
public long getUploaded() { return 0; }
|
||||
public long getYourPages() { return 0; }
|
||||
};
|
||||
when(auditLogQueryService.getPulseStats(any(OffsetDateTime.class), any(UUID.class)))
|
||||
.thenReturn(stats);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 50)).thenReturn(List.of());
|
||||
|
||||
dashboardService.getPulse(userId);
|
||||
|
||||
verify(auditLogQueryService).findActivityFeed(userId, 50);
|
||||
verify(auditLogQueryService, never()).findActivityFeed(any(UUID.class), anyInt(), any(Set.class));
|
||||
}
|
||||
|
||||
private ActivityFeedRow mockFeedRow(UUID docId, String kind) {
|
||||
return mockFeedRow(docId, kind, null);
|
||||
}
|
||||
|
||||
private ActivityFeedRow mockFeedRow(UUID docId, String kind, UUID commentId) {
|
||||
return new ActivityFeedRow() {
|
||||
public String getKind() { return kind; }
|
||||
public UUID getActorId() { return null; }
|
||||
@@ -104,6 +226,10 @@ class DashboardServiceTest {
|
||||
public UUID getDocumentId() { return docId; }
|
||||
public Instant getHappenedAt() { return Instant.now(); }
|
||||
public boolean isYouMentioned() { return false; }
|
||||
public boolean isYouParticipated() { return false; }
|
||||
public int getCount() { return 1; }
|
||||
public Instant getHappenedAtUntil() { return null; }
|
||||
public UUID getCommentId() { return commentId; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,67 +2,103 @@ package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DocumentSearchResultTest {
|
||||
|
||||
private Document doc(UUID id) {
|
||||
return Document.builder()
|
||||
.id(id)
|
||||
private DocumentSearchItem item(UUID docId) {
|
||||
Document doc = Document.builder()
|
||||
.id(docId)
|
||||
.title("Test")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMatchData_total_equals_list_size() {
|
||||
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(
|
||||
List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(2L);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(2);
|
||||
assertThat(result.totalPages()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_empty_shortcut_has_zero_totalPages() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of());
|
||||
|
||||
assertThat(result.totalElements()).isZero();
|
||||
assertThat(result.totalPages()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
||||
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||
}
|
||||
|
||||
@Test
|
||||
void paged_factory_totalPages_rounds_up_on_remainder() {
|
||||
DocumentSearchResult result =
|
||||
DocumentSearchResult.paged(List.of(), PageRequest.of(0, 7), 30L);
|
||||
|
||||
assertThat(result.totalPages()).isEqualTo(5); // ceil(30 / 7)
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_exposes_items_with_completion_and_contributors() {
|
||||
UUID id = UUID.randomUUID();
|
||||
List<Document> docs = List.of(doc(id));
|
||||
Map<UUID, SearchMatchData> matchData = Map.of(id, SearchMatchData.empty());
|
||||
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
|
||||
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData);
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1L);
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().get(0).completionPercentage()).isEqualTo(75);
|
||||
assertThat(result.items().get(0).contributors()).containsExactly(actor);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMatchData_exposes_match_data_map() {
|
||||
UUID id = UUID.randomUUID();
|
||||
SearchMatchData data = new SearchMatchData("snippet", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
DocumentSearchResult result = DocumentSearchResult.withMatchData(List.of(doc(id)), Map.of(id, data));
|
||||
|
||||
assertThat(result.matchData()).containsKey(id);
|
||||
assertThat(result.matchData().get(id).transcriptionSnippet()).isEqualTo("snippet");
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_factory_returns_empty_match_data() {
|
||||
UUID id = UUID.randomUUID();
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(doc(id)));
|
||||
|
||||
assertThat(result.matchData()).isEmpty();
|
||||
assertThat(result.total()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void documents_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("documents").getAnnotation(Schema.class);
|
||||
void items_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("items").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
|
||||
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
|
||||
assertThat(schema).as(name + " must have @Schema").isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DocumentTest {
|
||||
|
||||
@Test
|
||||
void getThumbnailUrl_returnsNull_whenThumbnailKeyNull() {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey(null)
|
||||
.build();
|
||||
|
||||
assertThat(doc.getThumbnailUrl()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getThumbnailUrl_omitsCacheBuster_whenThumbnailKeyPresentButGeneratedAtNull() {
|
||||
UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555");
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||
.thumbnailGeneratedAt(null)
|
||||
.build();
|
||||
|
||||
assertThat(doc.getThumbnailUrl())
|
||||
.isEqualTo("/api/documents/" + id + "/thumbnail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getThumbnailUrl_includesCacheBuster_whenBothKeyAndGeneratedAtPresent() {
|
||||
UUID id = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
LocalDateTime generatedAt = LocalDateTime.of(2026, 4, 23, 14, 30, 45);
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||
.thumbnailGeneratedAt(generatedAt)
|
||||
.build();
|
||||
|
||||
// frontend equivalent: `?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`
|
||||
// where thumbnailGeneratedAt is the ISO-8601 string Jackson serialises.
|
||||
// LocalDateTime.toString() produces "2026-04-23T14:30:45"; encodeURIComponent
|
||||
// turns ":" into "%3A" but leaves "T" and digits alone.
|
||||
String expected = "/api/documents/" + id + "/thumbnail?v=2026-04-23T14%3A30%3A45";
|
||||
assertThat(doc.getThumbnailUrl()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void thumbnailUrl_isSerialisedToJson_soFrontendReceivesIt() throws Exception {
|
||||
UUID id = UUID.fromString("99999999-aaaa-bbbb-cccc-111122223333");
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
|
||||
.build();
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
String json = mapper.writeValueAsString(doc);
|
||||
|
||||
// Locks the wire contract, not just the Java API: every Document JSON must carry
|
||||
// `thumbnailUrl`. Protects against silent breakage if the getter gets renamed,
|
||||
// hidden behind @JsonIgnore, or visibility-reduced — any of which would leave the
|
||||
// frontend rendering the fallback icon on every surface.
|
||||
assertThat(json).contains("\"thumbnailUrl\":\"" + doc.getThumbnailUrl() + "\"");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.model.ThumbnailAspect;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
@@ -65,6 +66,40 @@ class DocumentRepositoryTest {
|
||||
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
|
||||
}
|
||||
|
||||
// ─── thumbnailAspect + pageCount round-trip ───────────────────────────────
|
||||
|
||||
@Test
|
||||
void save_persistsThumbnailAspectAndPageCount() {
|
||||
Document document = Document.builder()
|
||||
.title("Mit Aspekt")
|
||||
.originalFilename("aspect.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailAspect(ThumbnailAspect.LANDSCAPE)
|
||||
.pageCount(7)
|
||||
.build();
|
||||
|
||||
Document saved = documentRepository.save(document);
|
||||
Document found = documentRepository.findById(saved.getId()).orElseThrow();
|
||||
|
||||
assertThat(found.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
|
||||
assertThat(found.getPageCount()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_thumbnailAspectAndPageCount_defaultToNull() {
|
||||
Document document = Document.builder()
|
||||
.title("Ohne Aspekt")
|
||||
.originalFilename("no_aspect.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.build();
|
||||
|
||||
Document saved = documentRepository.save(document);
|
||||
Document found = documentRepository.findById(saved.getId()).orElseThrow();
|
||||
|
||||
assertThat(found.getThumbnailAspect()).isNull();
|
||||
assertThat(found.getPageCount()).isNull();
|
||||
}
|
||||
|
||||
// ─── findByStatus ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -302,6 +302,102 @@ class MigrationIntegrationTest {
|
||||
).isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
// ─── V53: add thumbnail_aspect + page_count columns to documents ─────────
|
||||
|
||||
@Test
|
||||
void v53_thumbnailAspectColumn_existsAndIsNullable() {
|
||||
UUID docId = createDocument();
|
||||
|
||||
// Column must exist and accept NULL (freshly-created doc has no thumbnail yet)
|
||||
String aspect = jdbc.queryForObject(
|
||||
"SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId);
|
||||
assertThat(aspect).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void v53_pageCountColumn_existsAndIsNullable() {
|
||||
UUID docId = createDocument();
|
||||
|
||||
Integer pageCount = jdbc.queryForObject(
|
||||
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
|
||||
assertThat(pageCount).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() {
|
||||
UUID docId = createDocument();
|
||||
|
||||
int portraitRows = jdbc.update(
|
||||
"UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId);
|
||||
assertThat(portraitRows).isEqualTo(1);
|
||||
|
||||
int landscapeRows = jdbc.update(
|
||||
"UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId);
|
||||
assertThat(landscapeRows).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v53_pageCountColumn_storesInteger() {
|
||||
UUID docId = createDocument();
|
||||
|
||||
jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId);
|
||||
|
||||
Integer stored = jdbc.queryForObject(
|
||||
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
|
||||
assertThat(stored).isEqualTo(4);
|
||||
}
|
||||
|
||||
// ─── V51: backfill annotation_id on block comments and notifications ─────
|
||||
|
||||
@Test
|
||||
void v51_backfillsAnnotationIdOnBlockCommentsFromTheirBlocks() {
|
||||
UUID docId = createDocument();
|
||||
UUID annotationId = insertAnnotation(docId);
|
||||
UUID blockId = insertBlock(docId, annotationId);
|
||||
UUID commentId = insertBlockCommentWithNullAnnotationId(docId, blockId);
|
||||
|
||||
jdbc.update(V51_BACKFILL_COMMENTS_SQL);
|
||||
|
||||
UUID stored = jdbc.queryForObject(
|
||||
"SELECT annotation_id FROM document_comments WHERE id = ?",
|
||||
UUID.class, commentId);
|
||||
assertThat(stored).isEqualTo(annotationId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v51_backfillsAnnotationIdOnNotificationsFromTheirReferencedComment() {
|
||||
UUID docId = createDocument();
|
||||
UUID userId = insertUser("recipient-" + UUID.randomUUID() + "@example.com");
|
||||
UUID annotationId = insertAnnotation(docId);
|
||||
UUID blockId = insertBlock(docId, annotationId);
|
||||
UUID commentId = insertBlockCommentWithAnnotationId(docId, blockId, annotationId);
|
||||
UUID notificationId = insertNotificationWithNullAnnotationId(docId, commentId, userId);
|
||||
|
||||
jdbc.update(V51_BACKFILL_NOTIFICATIONS_SQL);
|
||||
|
||||
UUID stored = jdbc.queryForObject(
|
||||
"SELECT annotation_id FROM notifications WHERE id = ?",
|
||||
UUID.class, notificationId);
|
||||
assertThat(stored).isEqualTo(annotationId);
|
||||
}
|
||||
|
||||
private static final String V51_BACKFILL_COMMENTS_SQL = """
|
||||
UPDATE document_comments dc
|
||||
SET annotation_id = tb.annotation_id
|
||||
FROM transcription_blocks tb
|
||||
WHERE dc.block_id = tb.id
|
||||
AND dc.annotation_id IS NULL
|
||||
""";
|
||||
|
||||
private static final String V51_BACKFILL_NOTIFICATIONS_SQL = """
|
||||
UPDATE notifications n
|
||||
SET annotation_id = dc.annotation_id
|
||||
FROM document_comments dc
|
||||
WHERE n.reference_id = dc.id
|
||||
AND n.annotation_id IS NULL
|
||||
AND dc.annotation_id IS NOT NULL
|
||||
""";
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private UUID createPerson(String firstName, String lastName) {
|
||||
@@ -326,4 +422,63 @@ class MigrationIntegrationTest {
|
||||
em.flush();
|
||||
return doc.getId();
|
||||
}
|
||||
|
||||
private UUID insertAnnotation(UUID docId) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("""
|
||||
INSERT INTO document_annotations
|
||||
(id, document_id, page_number, x, y, width, height, color)
|
||||
VALUES (?, ?, 1, 0.1, 0.1, 0.3, 0.1, '#00C7B1')
|
||||
""", id, docId);
|
||||
return id;
|
||||
}
|
||||
|
||||
private UUID insertBlock(UUID docId, UUID annotationId) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("""
|
||||
INSERT INTO transcription_blocks
|
||||
(id, annotation_id, document_id, text, sort_order)
|
||||
VALUES (?, ?, ?, '', 0)
|
||||
""", id, annotationId, docId);
|
||||
return id;
|
||||
}
|
||||
|
||||
private UUID insertUser(String email) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("""
|
||||
INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention)
|
||||
VALUES (?, ?, 'hash', true, false, false)
|
||||
""", id, email);
|
||||
return id;
|
||||
}
|
||||
|
||||
private UUID insertBlockCommentWithNullAnnotationId(UUID docId, UUID blockId) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("""
|
||||
INSERT INTO document_comments
|
||||
(id, document_id, block_id, annotation_id, author_name, content)
|
||||
VALUES (?, ?, ?, NULL, 'Tester', 'Hi')
|
||||
""", id, docId, blockId);
|
||||
return id;
|
||||
}
|
||||
|
||||
private UUID insertBlockCommentWithAnnotationId(UUID docId, UUID blockId, UUID annotationId) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("""
|
||||
INSERT INTO document_comments
|
||||
(id, document_id, block_id, annotation_id, author_name, content)
|
||||
VALUES (?, ?, ?, ?, 'Tester', 'Hi')
|
||||
""", id, docId, blockId, annotationId);
|
||||
return id;
|
||||
}
|
||||
|
||||
private UUID insertNotificationWithNullAnnotationId(UUID docId, UUID commentId, UUID recipientId) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("""
|
||||
INSERT INTO notifications
|
||||
(id, recipient_id, type, document_id, reference_id, annotation_id, read, actor_name)
|
||||
VALUES (?, ?, 'MENTION', ?, ?, NULL, false, 'Tester')
|
||||
""", id, recipientId, docId, commentId);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class TranscriptionBlockRepositoryIntegrationTest {
|
||||
|
||||
static final UUID DOC_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
static final UUID DOC_B = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
static final UUID ANN_A = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
static final UUID ANN_B = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
|
||||
@Autowired TranscriptionBlockRepository repository;
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, true)"
|
||||
})
|
||||
void findCompletionStats_returns_100_when_all_blocks_reviewed() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_A);
|
||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)"
|
||||
})
|
||||
void findCompletionStats_returns_0_when_no_blocks_reviewed() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')"
|
||||
})
|
||||
void findCompletionStats_returns_empty_when_document_has_no_blocks() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
||||
|
||||
assertThat(rows).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 2, false)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 3, false)"
|
||||
})
|
||||
void findCompletionStats_rounds_partial_completion_correctly() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
||||
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(25);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Doc B', 'b.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 0, false)"
|
||||
})
|
||||
void findCompletionStats_handles_multiple_documents_in_one_call() {
|
||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A, DOC_B));
|
||||
|
||||
Map<UUID, Integer> byDoc = rows.stream()
|
||||
.collect(Collectors.toMap(CompletionStatsRow::getDocumentId, CompletionStatsRow::getCompletionPercentage));
|
||||
|
||||
assertThat(byDoc).containsEntry(DOC_A, 100);
|
||||
assertThat(byDoc).containsEntry(DOC_B, 0);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
|
||||
@@ -39,54 +40,9 @@ class CommentServiceTest {
|
||||
@Mock UserService userService;
|
||||
@Mock NotificationService notificationService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock TranscriptionService transcriptionService;
|
||||
@InjectMocks CommentService commentService;
|
||||
|
||||
// ─── postComment ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void postComment_capturesAuthorNameAtWriteTime() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_fallsBackToUsername_whenNamesAreBlank() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID mentionedId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build();
|
||||
|
||||
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postComment(docId, null, "Hey @Anna S", List.of(mentionedId), author);
|
||||
|
||||
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
||||
}
|
||||
|
||||
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -222,7 +178,7 @@ class CommentServiceTest {
|
||||
.id(commentId).documentId(docId).authorId(authorId)
|
||||
.content("Original").authorName("Hans").createdAt(created).build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
stubSaveAssigningRandomId();
|
||||
|
||||
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
||||
|
||||
@@ -282,28 +238,6 @@ class CommentServiceTest {
|
||||
verify(commentRepository).delete(comment);
|
||||
}
|
||||
|
||||
// ─── getCommentsForDocument ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getCommentsForDocument_returnsRootsWithRepliesAttached() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(rootId).documentId(docId).authorName("Hans").content("Root").build();
|
||||
DocumentComment reply = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build();
|
||||
|
||||
when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId))
|
||||
.thenReturn(List.of(root));
|
||||
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply));
|
||||
|
||||
List<DocumentComment> result = commentService.getCommentsForDocument(docId);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
||||
}
|
||||
|
||||
// ─── replyToComment — reply with null authorId in thread ─────────────────
|
||||
|
||||
@Test
|
||||
@@ -330,82 +264,6 @@ class CommentServiceTest {
|
||||
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||
}
|
||||
|
||||
// ─── resolveAuthorName edge cases ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||
.firstName(" ").lastName(null).build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("user42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||
.firstName(null).lastName(" ").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("user42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_includesOnlyFirstName_whenLastNameIsNull() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||
.firstName("Hans").lastName(null).build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||
|
||||
// first != null && !blank → true; last == null → entire condition false → returns stripped first
|
||||
verify(commentRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_includesOnlyLastName_whenFirstNameIsNull() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||
.firstName(null).lastName("Müller").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||
|
||||
// No exception — name resolution with null first name strips cleanly
|
||||
verify(commentRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── saveMentions — null/empty guard ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
|
||||
.firstName("Hans").lastName("M").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postComment(docId, null, "Hi", null, author);
|
||||
|
||||
verify(userService, never()).findAllById(anyList());
|
||||
}
|
||||
|
||||
// ─── collectParticipantIds — non-null authorId in reply ──────────────────
|
||||
|
||||
@Test
|
||||
@@ -459,26 +317,6 @@ class CommentServiceTest {
|
||||
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||
}
|
||||
|
||||
// ─── getCommentsForAnnotation ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getCommentsForAnnotation_returnsRootsForAnnotation() {
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(rootId).annotationId(annotationId).authorName("Hans").content("Root").build();
|
||||
|
||||
when(commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId))
|
||||
.thenReturn(List.of(root));
|
||||
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||
|
||||
List<DocumentComment> result = commentService.getCommentsForAnnotation(annotationId);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getAnnotationId()).isEqualTo(annotationId);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private AppUser buildAdmin() {
|
||||
@@ -495,65 +333,6 @@ class CommentServiceTest {
|
||||
|
||||
// ─── audit: COMMENT_ADDED and MENTION_CREATED ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void postComment_logsCommentAdded() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID savedId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(savedId).documentId(docId).authorName("Hans M").content("Hello").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postComment(docId, null, "Hello", List.of(), author);
|
||||
|
||||
verify(auditService).logAfterCommit(
|
||||
eq(AuditKind.COMMENT_ADDED),
|
||||
eq(author.getId()),
|
||||
eq(docId),
|
||||
argThat(p -> savedId.toString().equals(p.get("commentId"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_logsMentionCreated_oncePerMentionedUser() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID savedId = UUID.randomUUID();
|
||||
UUID mentionedId1 = UUID.randomUUID();
|
||||
UUID mentionedId2 = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||
AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build();
|
||||
AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(savedId).documentId(docId).authorName("Hans M").content("Hey @Anna @Bob").build();
|
||||
when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2));
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postComment(docId, null, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author);
|
||||
|
||||
verify(auditService).logAfterCommit(
|
||||
eq(AuditKind.MENTION_CREATED),
|
||||
eq(author.getId()),
|
||||
eq(docId),
|
||||
argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId"))));
|
||||
verify(auditService).logAfterCommit(
|
||||
eq(AuditKind.MENTION_CREATED),
|
||||
eq(author.getId()),
|
||||
eq(docId),
|
||||
argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_doesNotLogMentionCreated_whenNoMentions() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hello").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postComment(docId, null, "Hello", List.of(), author);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyToComment_logsCommentAdded() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
@@ -611,6 +390,8 @@ class CommentServiceTest {
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("B").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(savedId).documentId(docId).blockId(blockId).authorName("Felix B").content("Nice").build();
|
||||
when(transcriptionService.getBlock(docId, blockId))
|
||||
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build());
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postBlockComment(docId, blockId, "Nice", List.of(), author);
|
||||
@@ -643,7 +424,10 @@ class CommentServiceTest {
|
||||
void postBlockComment_setsBlockIdOnComment() {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
||||
when(transcriptionService.getBlock(documentId, blockId))
|
||||
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build());
|
||||
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||
DocumentComment c = inv.getArgument(0);
|
||||
c.setId(UUID.randomUUID());
|
||||
@@ -657,4 +441,275 @@ class CommentServiceTest {
|
||||
assertThat(result.getDocumentId()).isEqualTo(documentId);
|
||||
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_setsAnnotationIdFromBlock() {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
||||
when(transcriptionService.getBlock(documentId, blockId))
|
||||
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build());
|
||||
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||
DocumentComment c = inv.getArgument(0);
|
||||
c.setId(UUID.randomUUID());
|
||||
return c;
|
||||
});
|
||||
|
||||
DocumentComment result = commentService.postBlockComment(
|
||||
documentId, blockId, "Nice work", List.of(), author);
|
||||
|
||||
assertThat(result.getAnnotationId()).isEqualTo(annotationId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_propagatesNotFound_whenBlockDoesNotExist() {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
||||
when(transcriptionService.getBlock(documentId, blockId))
|
||||
.thenThrow(DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND,
|
||||
"Transcription block not found: " + blockId));
|
||||
|
||||
assertThatThrownBy(() -> commentService.postBlockComment(documentId, blockId, "Hi", List.of(), author))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("Transcription block not found");
|
||||
}
|
||||
|
||||
// ─── postBlockComment — authorName resolution ────────────────────────────
|
||||
|
||||
@Test
|
||||
void postBlockComment_capturesAuthorNameAtWriteTime() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build();
|
||||
stubBlock(docId, blockId);
|
||||
stubSaveAssigningRandomId();
|
||||
|
||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_fallsBackToEmail_whenNamesAreBlank() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build();
|
||||
stubBlock(docId, blockId);
|
||||
stubSaveAssigningRandomId();
|
||||
|
||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("hans42@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_fallsBackToEmail_whenFirstNameBlankAndLastNameNull() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||
.firstName(" ").lastName(null).build();
|
||||
stubBlock(docId, blockId);
|
||||
stubSaveAssigningRandomId();
|
||||
|
||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("user42@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_fallsBackToEmail_whenFirstNameNullAndLastNameBlank() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||
.firstName(null).lastName(" ").build();
|
||||
stubBlock(docId, blockId);
|
||||
stubSaveAssigningRandomId();
|
||||
|
||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("user42@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_usesFirstNameAlone_whenLastNameIsNull() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||
.firstName("Hans").lastName(null).build();
|
||||
stubBlock(docId, blockId);
|
||||
stubSaveAssigningRandomId();
|
||||
|
||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("Hans");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_usesLastNameAlone_whenFirstNameIsNull() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||
.firstName(null).lastName("Müller").build();
|
||||
stubBlock(docId, blockId);
|
||||
stubSaveAssigningRandomId();
|
||||
|
||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("Müller");
|
||||
}
|
||||
|
||||
// ─── postBlockComment — mentions ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void postBlockComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
UUID mentionedId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna S").build();
|
||||
stubBlock(docId, blockId);
|
||||
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postBlockComment(docId, blockId, "Hey @Anna S", List.of(mentionedId), author);
|
||||
|
||||
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
|
||||
.firstName("Hans").lastName("M").build();
|
||||
stubBlock(docId, blockId);
|
||||
stubSaveAssigningRandomId();
|
||||
|
||||
commentService.postBlockComment(docId, blockId, "Hi", null, author);
|
||||
|
||||
verify(userService, never()).findAllById(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_logsMentionCreated_oncePerMentionedUser() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
UUID savedId = UUID.randomUUID();
|
||||
UUID mentionedId1 = UUID.randomUUID();
|
||||
UUID mentionedId2 = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||
AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build();
|
||||
AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(savedId).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna @Bob").build();
|
||||
stubBlock(docId, blockId);
|
||||
when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2));
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postBlockComment(docId, blockId, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author);
|
||||
|
||||
verify(auditService).logAfterCommit(
|
||||
eq(AuditKind.MENTION_CREATED),
|
||||
eq(author.getId()),
|
||||
eq(docId),
|
||||
argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId"))));
|
||||
verify(auditService).logAfterCommit(
|
||||
eq(AuditKind.MENTION_CREATED),
|
||||
eq(author.getId()),
|
||||
eq(docId),
|
||||
argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postBlockComment_doesNotLogMentionCreated_whenNoMentions() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hello").build();
|
||||
stubBlock(docId, blockId);
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postBlockComment(docId, blockId, "Hello", List.of(), author);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
|
||||
UUID commentA = UUID.randomUUID();
|
||||
UUID annotationA = UUID.randomUUID();
|
||||
UUID commentB = UUID.randomUUID();
|
||||
UUID annotationB = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(commentA, commentB)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(commentA).annotationId(annotationA).build(),
|
||||
DocumentComment.builder().id(commentB).annotationId(annotationB).build()
|
||||
));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(commentA, commentB)))
|
||||
.containsOnly(
|
||||
java.util.Map.entry(commentA, annotationA),
|
||||
java.util.Map.entry(commentB, annotationB)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_returnsEmptyMap_forEmptyInput() {
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of())).isEmpty();
|
||||
verify(commentRepository, never()).findAllById(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_omitsUnknownIds() {
|
||||
UUID known = UUID.randomUUID();
|
||||
UUID knownAnnotation = UUID.randomUUID();
|
||||
UUID missing = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(known, missing)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
|
||||
));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
|
||||
.containsOnly(java.util.Map.entry(known, knownAnnotation))
|
||||
.doesNotContainKey(missing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
|
||||
UUID legacy = UUID.randomUUID();
|
||||
UUID block = UUID.randomUUID();
|
||||
UUID annotation = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(legacy, block)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(legacy).annotationId(null).build(),
|
||||
DocumentComment.builder().id(block).annotationId(annotation).build()
|
||||
));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
|
||||
.containsOnly(java.util.Map.entry(block, annotation))
|
||||
.doesNotContainKey(legacy);
|
||||
}
|
||||
|
||||
private void stubBlock(UUID docId, UUID blockId) {
|
||||
when(transcriptionService.getBlock(docId, blockId))
|
||||
.thenReturn(TranscriptionBlock.builder()
|
||||
.id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build());
|
||||
}
|
||||
|
||||
private void stubSaveAssigningRandomId() {
|
||||
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||
DocumentComment c = inv.getArgument(0);
|
||||
if (c.getId() == null) c.setId(UUID.randomUUID());
|
||||
return c;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
|
||||
* Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
|
||||
* UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
|
||||
* against the actual JPA query.
|
||||
*
|
||||
* <p>Closes the integration-coverage gap Sara flagged on PR #316.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class DocumentSearchPagedIntegrationTest {
|
||||
|
||||
private static final int FIXTURE_SIZE = 120;
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
// Deterministic date spread so DATE-DESC order is predictable:
|
||||
// document #0 has the oldest date, document #119 has the newest.
|
||||
for (int i = 0; i < FIXTURE_SIZE; i++) {
|
||||
Document doc = Document.builder()
|
||||
.title("Dok-" + String.format("%03d", i))
|
||||
.originalFilename("dok-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
|
||||
.build();
|
||||
documentRepository.save(doc);
|
||||
}
|
||||
assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_lastPartialPage_returnsRemainingItems() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(2, 50));
|
||||
|
||||
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
||||
assertThat(result.items()).hasSize(20);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(99, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
|
||||
// SENDER sort path fetches all + sorts + slices in-memory (see scaling
|
||||
// comment in DocumentService). Proves that the in-memory slice path
|
||||
// returns the correct total from a real repository fetch.
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.SENDER, "asc", null,
|
||||
PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_differentPagesReturnDisjointSlices() {
|
||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(1, 50));
|
||||
|
||||
// No document id should appear on both pages — slicing must be exclusive.
|
||||
var idsOnPage0 = page0.items().stream()
|
||||
.map(item -> item.document().getId())
|
||||
.toList();
|
||||
var idsOnPage1 = page1.items().stream()
|
||||
.map(item -> item.document().getId())
|
||||
.toList();
|
||||
for (UUID id : idsOnPage0) {
|
||||
assertThat(idsOnPage1).doesNotContain(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -24,12 +26,16 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceSortTest {
|
||||
|
||||
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@Mock TagService tagService;
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@Mock AnnotationService annotationService;
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
|
||||
@@ -48,16 +54,16 @@ class DocumentServiceSortTest {
|
||||
|
||||
// FTS returns id1 first (higher rank), id2 second
|
||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||
// findAll(spec, sort) — the correct date path — returns date-DESC order
|
||||
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(newer, older));
|
||||
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
||||
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
|
||||
|
||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||
assertThat(result.documents()).hasSize(2);
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
|
||||
@@ -75,10 +81,10 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
// Expect: rank order restored (id1 first)
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -94,8 +100,8 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(List.of(doc2, doc1));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||
|
||||
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
@@ -22,6 +24,7 @@ import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
@@ -44,6 +47,12 @@ import static org.mockito.Mockito.*;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceTest {
|
||||
|
||||
// Used by tests that don't care about paging. 10 000 is chosen large enough
|
||||
// to hold any fixture in this file but small enough that totalPages math
|
||||
// stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
|
||||
// red flag — use this constant.
|
||||
private static final Pageable UNPAGED = PageRequest.of(0, 10_000);
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@@ -51,6 +60,9 @@ class DocumentServiceTest {
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@Mock AnnotationService annotationService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
@@ -253,6 +265,107 @@ class DocumentServiceTest {
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
// ─── thumbnail dispatch ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void storeDocument_dispatchesThumbnailAfterSave() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
|
||||
UUID savedId = UUID.randomUUID();
|
||||
Document saved = Document.builder().id(savedId).originalFilename("new.pdf").build();
|
||||
when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
|
||||
|
||||
documentService.storeDocument(file, null);
|
||||
|
||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_dispatchesThumbnail_onlyWhenFileProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("No file");
|
||||
UUID savedId = UUID.randomUUID();
|
||||
Document saved = Document.builder().id(savedId).title("No file")
|
||||
.originalFilename("No file").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verifyNoInteractions(thumbnailAsyncRunner);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_dispatchesThumbnail_whenFileProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("With file");
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
UUID savedId = UUID.randomUUID();
|
||||
Document saved = Document.builder().id(savedId).title("With file")
|
||||
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
when(fileService.uploadFile(any(), any()))
|
||||
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
|
||||
|
||||
documentService.createDocument(dto, file);
|
||||
|
||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_dispatchesThumbnail_onlyWhenFileReplaced() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Doc").originalFilename("old.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
documentService.updateDocument(id, new DocumentUpdateDTO(), null, null);
|
||||
|
||||
verifyNoInteractions(thumbnailAsyncRunner);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_dispatchesThumbnail_whenNewFileProvided() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Doc").originalFilename("old.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
org.springframework.mock.web.MockMultipartFile newFile =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
when(fileService.uploadFile(any(), any()))
|
||||
.thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
|
||||
|
||||
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile, null);
|
||||
|
||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attachFile_dispatchesThumbnailAfterSave() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Placeholder").originalFilename("placeholder")
|
||||
.status(DocumentStatus.PLACEHOLDER).build();
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
when(fileService.uploadFile(any(), any()))
|
||||
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
|
||||
|
||||
documentService.attachFile(id, file, null);
|
||||
|
||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
|
||||
}
|
||||
|
||||
// ─── storeDocument ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -386,6 +499,22 @@ class DocumentServiceTest {
|
||||
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIncompleteDocuments_mapsUploadedAtFromCreatedAt() {
|
||||
java.time.LocalDateTime createdAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Recent")
|
||||
.createdAt(createdAt)
|
||||
.build();
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(doc)));
|
||||
|
||||
List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
|
||||
|
||||
assertThat(result.get(0).uploadedAt()).isEqualTo(createdAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIncompleteDocuments_passesSizeToPageable() {
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||
@@ -1201,26 +1330,124 @@ class DocumentServiceTest {
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
// ─── searchDocuments — pagination ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_propagatesPageableToDatabase() {
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(3, 25));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
||||
assertThat(captor.getValue().getPageSize()).isEqualTo(25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
|
||||
// The service MUST report the full match count from Page.getTotalElements(),
|
||||
// not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
|
||||
Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
|
||||
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
||||
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
||||
// back with totalElements = 120.
|
||||
List<Document> all = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 120; i++) {
|
||||
Person p = Person.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.firstName("F" + i)
|
||||
.lastName(String.format("L%03d", i))
|
||||
.build();
|
||||
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||
}
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
assertThat(result.items()).hasSize(50);
|
||||
// Page 1 (offset 50) under ascending sender sort should start at L050
|
||||
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
|
||||
// Guards the JPA edge case where page * size > totalElements.
|
||||
// Must not throw, must return empty content + correct totalElements.
|
||||
List<Document> all = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 30; i++) {
|
||||
Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
|
||||
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||
}
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(30L);
|
||||
}
|
||||
|
||||
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
|
||||
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||
@@ -1296,10 +1523,10 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(withSender, noSender));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
|
||||
assertThat(result.documents()).hasSize(2);
|
||||
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||
@@ -1316,9 +1543,9 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(noReceivers, withReceiver));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||
|
||||
assertThat(result.documents()).extracting(Document::getTitle)
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
}
|
||||
|
||||
@@ -1338,10 +1565,10 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(docNullName, docSmith));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result.documents()).extracting(Document::getTitle)
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
.containsExactly("smith doc", "Null lastname doc");
|
||||
}
|
||||
|
||||
@@ -1360,23 +1587,24 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
assertThat(result.matchData()).containsKey(docId);
|
||||
SearchMatchData md = result.matchData().get(docId);
|
||||
assertThat(result.items()).hasSize(1);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.titleOffsets()).hasSize(1);
|
||||
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
UNPAGED);
|
||||
|
||||
assertThat(result.matchData()).isEmpty();
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1393,9 +1621,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
SearchMatchData md = result.matchData().get(docId);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
|
||||
}
|
||||
@@ -1585,4 +1813,108 @@ class DocumentServiceTest {
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
||||
}
|
||||
|
||||
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
|
||||
|
||||
private MockMultipartFile pdfFile(String name) {
|
||||
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
|
||||
}
|
||||
|
||||
private void stubStoreDocument(String filename) throws Exception {
|
||||
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
|
||||
stubStoreDocument("scan01.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
|
||||
|
||||
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
stubStoreDocument("scan02.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
Person sender = Person.builder().id(senderId).firstName("Anna").build();
|
||||
when(personService.getById(senderId)).thenReturn(sender);
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setSenderId(senderId);
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
|
||||
|
||||
assertThat(result.document().getSender()).isEqualTo(sender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||
Document d = inv.getArgument(0);
|
||||
if (d.getId() == null) d.setId(docId);
|
||||
return d;
|
||||
});
|
||||
when(documentRepository.findById(docId)).thenAnswer(inv -> {
|
||||
Document d = new Document();
|
||||
d.setId(docId);
|
||||
return Optional.of(d);
|
||||
});
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTagNames(List.of("Familie"));
|
||||
|
||||
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
|
||||
|
||||
verify(tagService).findOrCreate("Familie");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
|
||||
stubStoreDocument("scan04.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTitles(List.of("Only One Title"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
|
||||
|
||||
assertThat(result.document().getTitle()).isEqualTo("scan04");
|
||||
}
|
||||
|
||||
// ─── validateBatch ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
|
||||
assertThatThrownBy(() -> documentService.validateBatch(51, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("50");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
|
||||
documentService.validateBatch(50, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
|
||||
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
metadata.setTitles(java.util.List.of("A", "B", "C"));
|
||||
|
||||
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("titles");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,4 +197,39 @@ class FileServiceTest {
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("Failed to download");
|
||||
}
|
||||
|
||||
// ─── downloadFileStream ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void downloadFileStream_returnsStreamableContent() throws IOException {
|
||||
byte[] content = "streamed bytes".getBytes();
|
||||
GetObjectResponse response = GetObjectResponse.builder().contentType("application/pdf").build();
|
||||
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
|
||||
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
|
||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
|
||||
|
||||
try (java.io.InputStream result = fileService.downloadFileStream("documents/file.pdf")) {
|
||||
assertThat(result.readAllBytes()).isEqualTo(content);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadFileStream_throwsStorageFileNotFoundException_whenNoSuchKey() {
|
||||
NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
|
||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
||||
|
||||
assertThatThrownBy(() -> fileService.downloadFileStream("missing/key.pdf"))
|
||||
.isInstanceOf(FileService.StorageFileNotFoundException.class)
|
||||
.hasMessageContaining("missing/key.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadFileStream_throwsIOException_whenS3Exception() {
|
||||
S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
|
||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
||||
|
||||
assertThatThrownBy(() -> fileService.downloadFileStream("documents/file.pdf"))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("Failed to open stream");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,13 @@ class MassImportServiceTest {
|
||||
@Mock PersonService personService;
|
||||
@Mock TagService tagService;
|
||||
@Mock S3Client s3Client;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
|
||||
MassImportService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new MassImportService(documentRepository, personService, tagService, s3Client);
|
||||
service = new MassImportService(documentRepository, personService, tagService, s3Client, thumbnailAsyncRunner);
|
||||
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
||||
ReflectionTestUtils.setField(service, "colIndex", 0);
|
||||
ReflectionTestUtils.setField(service, "colBox", 1);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ThumbnailAsyncRunnerTest {
|
||||
|
||||
private DocumentRepository documentRepository;
|
||||
private ThumbnailService thumbnailService;
|
||||
private ThumbnailAsyncRunner runner;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
documentRepository = mock(DocumentRepository.class);
|
||||
thumbnailService = mock(ThumbnailService.class);
|
||||
runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
runner.dispatchAfterCommit(id);
|
||||
|
||||
verify(thumbnailService).generate(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
runner.dispatchAfterCommit(id);
|
||||
|
||||
// Nothing fired yet — registered, not executed
|
||||
verify(thumbnailService, never()).generate(any());
|
||||
|
||||
// Simulate commit
|
||||
ArgumentCaptor<TransactionSynchronization> captor =
|
||||
ArgumentCaptor.forClass(TransactionSynchronization.class);
|
||||
assertThat(TransactionSynchronizationManager.getSynchronizations()).hasSize(1);
|
||||
TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit();
|
||||
|
||||
verify(thumbnailService).generate(doc);
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchAfterCommit_whenRollback_doesNotDispatch() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
runner.dispatchAfterCommit(id);
|
||||
|
||||
// Simulate rollback — afterCompletion with STATUS_ROLLED_BACK, no afterCommit fired
|
||||
TransactionSynchronizationManager.getSynchronizations().get(0)
|
||||
.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
|
||||
verify(thumbnailService, never()).generate(any());
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateAsync_skipsWhenDocumentMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
runner.generateAsync(id);
|
||||
|
||||
verifyNoInteractions(thumbnailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
// generate sleeps longer than the timeout — simulates a hung PDFBox render
|
||||
when(thumbnailService.generate(doc)).thenAnswer(inv -> {
|
||||
Thread.sleep(5_000);
|
||||
return ThumbnailService.Outcome.SUCCESS;
|
||||
});
|
||||
// Shrink timeout for the test
|
||||
ReflectionTestUtils.setField(runner, "generateTimeoutSeconds", 1L);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
runner.generateAsync(id);
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
// Must return before the 5s sleep — within ~2s with timeout=1s plus overhead
|
||||
assertThat(elapsed).isLessThan(3_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ThumbnailBackfillServiceTest {
|
||||
|
||||
private DocumentRepository documentRepository;
|
||||
private ThumbnailService thumbnailService;
|
||||
private ThumbnailBackfillService backfillService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
documentRepository = mock(DocumentRepository.class);
|
||||
thumbnailService = mock(ThumbnailService.class);
|
||||
backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialStatus_isIdle() {
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.IDLE);
|
||||
assertThat(status.total()).isZero();
|
||||
assertThat(status.processed()).isZero();
|
||||
assertThat(status.startedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_processesAllDocumentsAndFinishesDone() {
|
||||
Document a = doc();
|
||||
Document b = doc();
|
||||
Document c = doc();
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(a, b, c));
|
||||
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
||||
assertThat(status.total()).isEqualTo(3);
|
||||
assertThat(status.processed()).isEqualTo(3);
|
||||
assertThat(status.skipped()).isZero();
|
||||
assertThat(status.failed()).isZero();
|
||||
verify(thumbnailService, times(3)).generate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_countsSkippedSeparately() {
|
||||
Document a = doc();
|
||||
Document b = doc();
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(a, b));
|
||||
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED);
|
||||
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
||||
assertThat(status.processed()).isEqualTo(1);
|
||||
assertThat(status.skipped()).isEqualTo(1);
|
||||
assertThat(status.failed()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_continuesAfterFailureAndCountsIt() {
|
||||
Document a = doc();
|
||||
Document b = doc();
|
||||
Document c = doc();
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(a, b, c));
|
||||
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED);
|
||||
when(thumbnailService.generate(c)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
||||
assertThat(status.processed()).isEqualTo(2);
|
||||
assertThat(status.failed()).isEqualTo(1);
|
||||
verify(thumbnailService, times(3)).generate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() {
|
||||
Document a = doc();
|
||||
Document b = doc();
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(a, b));
|
||||
when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom"));
|
||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
||||
assertThat(status.processed()).isEqualTo(1);
|
||||
assertThat(status.failed()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_rejectsConcurrentStart() {
|
||||
// Force state=RUNNING via reflection
|
||||
ThumbnailBackfillService.BackfillStatus running = new ThumbnailBackfillService.BackfillStatus(
|
||||
ThumbnailBackfillService.State.RUNNING, "running", 10, 5, 0, 0, LocalDateTime.now());
|
||||
ReflectionTestUtils.setField(backfillService, "currentStatus", running);
|
||||
|
||||
assertThatThrownBy(() -> backfillService.runBackfillAsync())
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBackfillAsync_setsStartedAtAndMessage() {
|
||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
||||
.thenReturn(List.of(doc()));
|
||||
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
|
||||
backfillService.runBackfillAsync();
|
||||
|
||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
||||
assertThat(status.startedAt()).isAfter(before);
|
||||
assertThat(status.message()).isNotBlank();
|
||||
}
|
||||
|
||||
private Document doc() {
|
||||
return Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("t")
|
||||
.originalFilename("f.pdf")
|
||||
.filePath("documents/f.pdf")
|
||||
.contentType("application/pdf")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Full round-trip integration test against real MinIO and real Postgres. Catches S3
|
||||
* signing / presigning issues that a mocked S3Client would miss — the rest of the
|
||||
* test pyramid mocks at the FileService boundary.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class ThumbnailServiceIntegrationTest {
|
||||
|
||||
private static final String BUCKET = "archive-documents";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
|
||||
static GenericContainer<?> minio = new GenericContainer<>("minio/minio:RELEASE.2024-06-13T22-53-53Z")
|
||||
.withEnv("MINIO_ROOT_USER", ACCESS_KEY)
|
||||
.withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY)
|
||||
.withCommand("server /data")
|
||||
.withExposedPorts(9000);
|
||||
|
||||
static {
|
||||
minio.start();
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void s3Properties(DynamicPropertyRegistry registry) {
|
||||
registry.add("app.s3.endpoint", () -> "http://" + minio.getHost() + ":" + minio.getMappedPort(9000));
|
||||
registry.add("app.s3.access-key", () -> ACCESS_KEY);
|
||||
registry.add("app.s3.secret-key", () -> SECRET_KEY);
|
||||
registry.add("app.s3.bucket", () -> BUCKET);
|
||||
registry.add("app.s3.region", () -> "eu-central-1");
|
||||
}
|
||||
|
||||
@Autowired S3Client s3Client;
|
||||
@Autowired ThumbnailService thumbnailService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
@Test
|
||||
void generate_writesDecodableJpegToMinio_readbackMatches() throws IOException {
|
||||
// Ensure bucket exists (the real app has a bootstrap container for this; in tests we do it here).
|
||||
// Re-creating is a no-op; wrap in try/catch because the SDK throws on "already owned".
|
||||
try (S3Client bootstrap = buildClient()) {
|
||||
try {
|
||||
bootstrap.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
|
||||
} catch (Exception ignored) {
|
||||
// already exists
|
||||
}
|
||||
}
|
||||
|
||||
// Persist first so Hibernate assigns the UUID — avoids StaleObjectState on a pre-set id
|
||||
Document persisted = documentRepository.save(Document.builder()
|
||||
.title("IT Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.contentType("application/pdf")
|
||||
.build());
|
||||
UUID docId = persisted.getId();
|
||||
String pdfKey = "documents/" + docId + "_test.pdf";
|
||||
|
||||
s3Client.putObject(PutObjectRequest.builder()
|
||||
.bucket(BUCKET)
|
||||
.key(pdfKey)
|
||||
.contentType("application/pdf")
|
||||
.build(),
|
||||
RequestBody.fromBytes(createSamplePdf()));
|
||||
|
||||
persisted.setFilePath(pdfKey);
|
||||
persisted = documentRepository.save(persisted);
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(persisted);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
Document reloaded = documentRepository.findById(docId).orElseThrow();
|
||||
assertThat(reloaded.getThumbnailKey()).isEqualTo("thumbnails/" + docId + ".jpg");
|
||||
assertThat(reloaded.getThumbnailGeneratedAt()).isNotNull();
|
||||
|
||||
// Read back from MinIO and verify it decodes as a JPEG of the expected width
|
||||
try (InputStream in = s3Client.getObject(GetObjectRequest.builder()
|
||||
.bucket(BUCKET).key(reloaded.getThumbnailKey()).build())) {
|
||||
byte[] jpegBytes = in.readAllBytes();
|
||||
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(jpegBytes));
|
||||
assertThat(decoded).isNotNull();
|
||||
assertThat(decoded.getWidth()).isEqualTo(240);
|
||||
}
|
||||
}
|
||||
|
||||
private static S3Client buildClient() {
|
||||
return S3Client.builder()
|
||||
.endpointOverride(URI.create("http://" + minio.getHost() + ":" + minio.getMappedPort(9000)))
|
||||
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.region(Region.of("eu-central-1"))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static byte[] createSamplePdf() throws IOException {
|
||||
try (PDDocument pdf = new PDDocument()) {
|
||||
pdf.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
pdf.save(bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.ThumbnailAspect;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ThumbnailServiceTest {
|
||||
|
||||
private FileService fileService;
|
||||
private S3Client s3Client;
|
||||
private DocumentRepository documentRepository;
|
||||
private ThumbnailService thumbnailService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileService = mock(FileService.class);
|
||||
s3Client = mock(S3Client.class);
|
||||
documentRepository = mock(DocumentRepository.class);
|
||||
thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository);
|
||||
ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket");
|
||||
when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsSkipped_whenDocumentHasNoFilePath() {
|
||||
Document doc = makeDoc("application/pdf", null);
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
|
||||
verifyNoInteractions(s3Client);
|
||||
assertThat(doc.getThumbnailKey()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsSkipped_forUnsupportedContentType() throws IOException {
|
||||
Document doc = makeDoc("application/msword", "documents/letter.doc");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(new byte[]{1, 2, 3}));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
|
||||
verifyNoInteractions(s3Client);
|
||||
assertThat(doc.getThumbnailKey()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_rendersPdf_uploadsJpeg_updatesEntity() throws IOException {
|
||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||
byte[] pdfBytes = createSamplePdf();
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(pdfBytes));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
|
||||
ArgumentCaptor<PutObjectRequest> putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
|
||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
||||
verify(s3Client).putObject(putCaptor.capture(), bodyCaptor.capture());
|
||||
|
||||
PutObjectRequest req = putCaptor.getValue();
|
||||
assertThat(req.bucket()).isEqualTo("test-bucket");
|
||||
assertThat(req.key()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
|
||||
assertThat(req.contentType()).isEqualTo("image/jpeg");
|
||||
|
||||
byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
|
||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
|
||||
assertThat(jpg).isNotNull();
|
||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
||||
|
||||
assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
|
||||
assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
|
||||
verify(documentRepository).save(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_rendersPng_uploadsJpegAtWidth240() throws IOException {
|
||||
Document doc = makeDoc("image/png", "documents/scan.png");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
|
||||
byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
|
||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
|
||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
||||
assertThat(jpg.getHeight()).isEqualTo(320); // 600x800 -> 240x320
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_rendersJpeg_uploadsScaledJpeg() throws IOException {
|
||||
Document doc = makeDoc("image/jpeg", "documents/photo.jpg");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
|
||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(
|
||||
readAll(bodyCaptor.getValue().contentStreamProvider().newStream())));
|
||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
||||
assertThat(jpg.getHeight()).isEqualTo(120); // 800x400 -> 240x120
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsFailed_whenS3PutThrows() throws IOException {
|
||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenThrow((S3Exception) S3Exception.builder().message("quota exceeded").statusCode(507).build());
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||
assertThat(doc.getThumbnailKey()).isNull();
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsFailed_whenSourceStreamThrows() throws IOException {
|
||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenThrow(new IOException("network blip"));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||
verifyNoInteractions(s3Client);
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException {
|
||||
// Image uploads are always a single page from the user's perspective.
|
||||
Document doc = makeDoc("image/png", "documents/scan.png");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
assertThat(doc.getPageCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_persistsPageCount_fromPdfDocument() throws IOException {
|
||||
Document doc = makeDoc("application/pdf", "documents/multi.pdf");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePdf(3)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
assertThat(doc.getPageCount()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException {
|
||||
// 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT.
|
||||
Document doc = makeDoc("image/png", "documents/portrait.png");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException {
|
||||
// 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE.
|
||||
Document doc = makeDoc("image/jpeg", "documents/wide.jpg");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException {
|
||||
// 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often
|
||||
// come in at near-square and we want them to live in the portrait tile).
|
||||
Document doc = makeDoc("image/png", "documents/square.png");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePng(500, 500)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException {
|
||||
// 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT.
|
||||
Document doc = makeDoc("image/png", "documents/near_threshold.png");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000)));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException {
|
||||
// Truncated JPEG header — ImageIO returns null rather than throwing.
|
||||
// Without the corrupt-image guard this would later NPE inside the aspect /
|
||||
// dimension computation in scaleToWidth.
|
||||
Document doc = makeDoc("image/jpeg", "documents/corrupt.jpg");
|
||||
byte[] truncated = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0};
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(truncated));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||
verifyNoInteractions(s3Client);
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsFailed_whenPdfBytesAreCorrupt() throws IOException {
|
||||
// "PDF" header but no body — PDFBox throws IOException while loading.
|
||||
Document doc = makeDoc("application/pdf", "documents/corrupt.pdf");
|
||||
byte[] fakePdf = "%PDF-1.4\n".getBytes();
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(fakePdf));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||
verifyNoInteractions(s3Client);
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException {
|
||||
// Covers the "orphan thumbnail" edge case: S3 upload succeeded but the
|
||||
// entity update blew up. We must still return FAILED so the backfill
|
||||
// tally is honest, without losing the fact that we already put bytes in S3.
|
||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
||||
when(fileService.downloadFileStream(anyString()))
|
||||
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
|
||||
when(documentRepository.save(any()))
|
||||
.thenThrow(new RuntimeException("constraint violation"));
|
||||
|
||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||
|
||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
verify(documentRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private Document makeDoc(String contentType, String filePath) {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Test Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.contentType(contentType)
|
||||
.filePath(filePath)
|
||||
.build();
|
||||
doc.setCreatedAt(LocalDateTime.now());
|
||||
doc.setUpdatedAt(LocalDateTime.now());
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static byte[] createSamplePdf() throws IOException {
|
||||
return createSamplePdf(1);
|
||||
}
|
||||
|
||||
private static byte[] createSamplePdf(int pageCount) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
|
||||
content.beginText();
|
||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
|
||||
content.newLineAtOffset(100, 700);
|
||||
content.showText("Lieber Hans,");
|
||||
content.endText();
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
doc.save(bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createSamplePng(int width, int height) throws IOException {
|
||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(Color.LIGHT_GRAY);
|
||||
g.fillRect(0, 0, width, height);
|
||||
g.setColor(Color.DARK_GRAY);
|
||||
g.fillRect(0, 0, width, height / 4);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] createSampleJpeg(int width, int height) throws IOException {
|
||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, width, height);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpg", bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] readAll(InputStream stream) throws IOException {
|
||||
try (stream) {
|
||||
return stream.readAllBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
59
docs/adr/003-chronik-unified-activity-feed.md
Normal file
59
docs/adr/003-chronik-unified-activity-feed.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# ADR-003: Session-Rollup Unified Activity Feed on `/chronik`
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The app had two disconnected ways to see what was happening in the archive:
|
||||
|
||||
1. `/notifications` — personal mentions/replies only, delivered via the `notifications` table and a Bell dropdown.
|
||||
2. Dashboard activity feed — ambient events (uploads, transcription, annotations, comments, mentions) via `/api/dashboard/activity`, which deduplicated using `DISTINCT ON (actor_id, document_id, kind, date_trunc('hour', happened_at))`.
|
||||
|
||||
Two separate lists was a poor mental model (personal vs. ambient feel the same to the user), the `/notifications` page wasted horizontal space, the dashboard's "Alle anzeigen" pointed to `/documents` (dead-end), and the hour-trunc dedupe produced ugly splits on natural sessions — saving 20 transcription blocks at 08:58, 08:59, 09:01 yielded two rows.
|
||||
|
||||
We needed one page that merges both streams, keeps personal mentions visually loud, and aggregates ambient noise coherently.
|
||||
|
||||
## Decision
|
||||
|
||||
**One page `/chronik` backed by two endpoints.** The SvelteKit `+page.server.ts` composes data from `/api/dashboard/activity` (for the ambient timeline) and `/api/notifications` (for the "Für dich" box). No new `/api/chronik` orchestrator — the frontend load function is the composition seam.
|
||||
|
||||
**Session-style rollup replaces hour-trunc dedupe everywhere.** `AuditLogQueryRepository.findDedupedActivityFeed` is renamed to `findRolledUpActivityFeed` and rewritten using a `LAG()`-based session algorithm:
|
||||
|
||||
```
|
||||
LAG(happened_at) OVER (PARTITION BY actor_id, document_id, kind ORDER BY happened_at)
|
||||
→ is_new_session = gap > 7200s (or first event in partition, or kind ∈ {COMMENT_ADDED, MENTION_CREATED})
|
||||
→ SUM(is_new_session) OVER (... ROWS UNBOUNDED PRECEDING) = session_id
|
||||
→ GROUP BY (actor_id, document_id, kind, session_id) → MIN(happened_at), MAX(...), COUNT(*)
|
||||
```
|
||||
|
||||
Events within 120 min on the same `(actor, document, kind)` become one row with `count` and `happenedAtUntil` fields. `COMMENT_ADDED` and `MENTION_CREATED` always start a new session — these kinds never roll up. No hard cap on total session span (a 4-hour transcription sitting is one row). The hour-trunc dedupe SQL is **deleted**, not kept alongside — one aggregation strategy per query.
|
||||
|
||||
**URL is universal German `/chronik` across all locales**, matching the existing convention (`/dokumente`, `/personen`, `/briefwechsel`). Content is translated via Paraglide; the URL is a stable German identifier, not a translatable route.
|
||||
|
||||
**DTO extended, not replaced.** `ActivityFeedItemDTO` gains `count: int` (required, `1` for singletons) and `happenedAtUntil: OffsetDateTime?` (null for singletons, end-of-session for rollups). One DTO shape serves both the Chronik timeline and the dashboard side-rail.
|
||||
|
||||
**`/notifications` route is deleted outright.** The app is pre-production — no 301 redirect, no zombie page.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Fixed 2-hour wall-clock buckets (`date_trunc('hour', happened_at / 2)`) | Splits natural sessions at bucket boundaries (e.g. events at 13:58 / 13:59 / 14:01 land in two rollup rows) |
|
||||
| Keep `DISTINCT ON hour-trunc` alongside new rollup query | Two aggregation strategies = zombie logic; dashboard and Chronik would drift |
|
||||
| New `/api/chronik` endpoint that merges both streams | Couples two domains (notifications + audit) at the API layer; composition belongs in `+page.server.ts` |
|
||||
| Localized URL slugs (`/chronik` / `/chronicle` / `/crónica`) | Breaks the project's existing German-URL convention and adds Paraglide routing overhead for zero UX value |
|
||||
| Per-locale rollup in the SQL (e.g. align to local-day boundaries) | Timezone-aware SQL is brittle; rollup is a time-gap concept, not a calendar-day concept |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- One hot path — `/api/dashboard/activity` is backed by a single partial covering index (`V49__add_audit_log_rollup_index.sql`) that matches the rollup query's WHERE clause exactly.
|
||||
- Dashboard side-rail gets rollup for free — 20 block-saves appear as one "Papa transkribierte 20 Blöcke" row with a time range, not 20 dedup'd hour buckets.
|
||||
- Component reuse — `ChronikRow.svelte` renders both singleton and rollup variants via a `$derived` discriminator; `DashboardActivityFeed.svelte` consumes the same DTO shape.
|
||||
|
||||
**Harder:**
|
||||
- The session SQL is ~15 lines longer than `DISTINCT ON`. That's the price for not splitting natural sessions at fixed boundaries — worth it on day one.
|
||||
- Historical `/api/dashboard/activity` consumers now see `count` and `happenedAtUntil`. No breaking change — `count` defaults to `1`, `happenedAtUntil` is nullable — but pre-existing tests needed updating.
|
||||
- Rollup is load-bearing for the UX — if the index is missing or the query regresses, the page either runs slow or returns duplicate rows. Covered by the rolledUp integration tests and the partial covering index; worth a follow-up Grafana panel on `/api/dashboard/activity` p95 latency.
|
||||
49
docs/adr/004-pdfbox-thumbnails.md
Normal file
49
docs/adr/004-pdfbox-thumbnails.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# ADR-004: In-Process PDFBox Thumbnails (not ocr-service)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The archive lists documents as text-only rows everywhere (home search, person detail, conversation timeline, Chronik). For a fundamentally visual archive — letters, scans, handwritten pages — this is a real discoverability problem. Issue #307 introduces a small JPEG thumbnail for every document.
|
||||
|
||||
A viable alternative to rendering in Spring Boot is delegating to the existing `ocr-service` (Python), which already has PyMuPDF/PIL available and is the project's designated place for PDF pixel work. The comparison is not obvious: either place works.
|
||||
|
||||
## Decision
|
||||
|
||||
Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (already a dependency for training-data export). A dedicated `thumbnailExecutor` pool isolates the work from the shared task pool used by OCR.
|
||||
|
||||
- PDF first page rendered via `PDFRenderer.renderImageWithDPI(0, 100, ImageType.RGB)`, scaled to 240 px width (bilinear) and encoded as JPEG quality 85.
|
||||
- Non-PDF image types (JPEG, PNG, TIFF) decoded via `javax.imageio` — TIFF requires the `twelvemonkeys-imageio-tiff` plugin on the classpath.
|
||||
- Upload paths fire-and-forget via `ThumbnailAsyncRunner.dispatchAfterCommit(docId)`; a `ThumbnailBackfillService` covers anything the async task missed or that pre-dates this feature.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Delegate to `ocr-service` (PyMuPDF) | Adds a network hop and a failure mode to every document upload. `ocr-service` is not guaranteed healthy at upload time (model-loading start period is 60 s). PDFBox is already a backend dependency — delegating is a net complexity increase. |
|
||||
| Render on the frontend with `pdfjs-dist` at display time | Would work for PDFs but not for scans / images; list pages would need to render dozens of PDFs on first paint; no server-side caching. |
|
||||
| Thumbor / imaginary / a dedicated thumbnail service | Overkill for a single-operator household tool; new container to operate and secure. |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- Zero new infrastructure. `thumbnails/` is a prefix in the existing MinIO bucket — production migration to Hetzner Object Storage works identically.
|
||||
- Backfill is a plain sequential loop; no inter-service retry semantics.
|
||||
- Integration test runs against real MinIO without needing `ocr-service` to be healthy.
|
||||
|
||||
**Harder:**
|
||||
- PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload).
|
||||
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
|
||||
|
||||
### Operational caveats (intentional)
|
||||
|
||||
**Backfill state is in-memory and single-node.** `ThumbnailBackfillService.currentStatus` is a volatile reference updated on the thumbnail executor thread. Restarting the backend mid-run loses progress and the next `runBackfillAsync()` starts over. This mirrors `MassImportService.ImportStatus` and is acceptable because the household archive runs as a single Spring Boot process, backfill is a rare one-shot admin action, and re-running the backfill is idempotent (`findByFilePathIsNotNullAndThumbnailKeyIsNull()` naturally skips completed documents).
|
||||
|
||||
**`ThumbnailService` and `ThumbnailBackfillService` inject `DocumentRepository` directly.** This is a deliberate exception to the project's "services never reach into another domain's repository" rule. Treating thumbnails as a cross-cutting aspect of `Document` rather than a sub-domain avoids a circular dependency (`DocumentService` → `ThumbnailAsyncRunner` → `DocumentService` would close the loop). If thumbnail state grows beyond two columns into its own domain model, extract a proper `ThumbnailRepository` at that point — not before.
|
||||
|
||||
## Future Direction
|
||||
|
||||
- If a second image-processing job (OCR region crops, sharing previews) arrives, revisit moving all image work to `ocr-service` so the two share a single PyMuPDF instance.
|
||||
- If thumbnails ever need to be generated at multiple sizes, switch the key pattern from `thumbnails/{docId}.jpg` to `thumbnails/{docId}/{width}.jpg` — the endpoint and cache-bust URL are already structured to accommodate that.
|
||||
52
docs/adr/005-thumbnail-aspect-and-page-count.md
Normal file
52
docs/adr/005-thumbnail-aspect-and-page-count.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# ADR-005: thumbnailAspect + pageCount alongside the thumbnail
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #305 rebalances the /briefwechsel correspondence list into PDF-thumbnail rows. Two pieces of metadata are needed at row-render time:
|
||||
|
||||
- **Aspect ratio** — postcards are landscape (7:5), letters are portrait (5:7). Forcing landscape scans into a portrait tile crops away the signature; forcing portrait scans into a landscape tile wastes horizontal real estate.
|
||||
- **Page count** — multi-page letters should show a "N" badge on their thumbnail so the reader can tell a single-page note from a seven-page letter without clicking in.
|
||||
|
||||
Both values are cheap to derive at the point the thumbnail is generated (the source image is already decoded; the PDF is already loaded) and impossible to derive cheaply later (requires re-reading the S3 object).
|
||||
|
||||
## Decision
|
||||
|
||||
Persist both values as columns on `documents` and populate them inside `ThumbnailService.generate()` — the same code path that writes the JPEG to S3 and stamps `thumbnail_generated_at`.
|
||||
|
||||
- `thumbnail_aspect VARCHAR(16)` mapped to a Java enum `ThumbnailAspect` with two values: `PORTRAIT`, `LANDSCAPE`.
|
||||
- `page_count INTEGER` — `PDDocument.getNumberOfPages()` for PDFs, `1` for image uploads.
|
||||
- Aspect threshold is `source.width / source.height > 1.1` → `LANDSCAPE`; everything else (including near-square A4 scans at ratio ≈ 1.0) stays `PORTRAIT`. The 1.1 margin keeps borderline scans from flipping across the threshold on a rounding error.
|
||||
- Both columns are nullable and remain `null` for historical documents until the existing `/api/admin/generate-thumbnails` backfill rerun populates them.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Derive aspect client-side after image load | First-paint would have all tiles in portrait, then reshuffle into landscape when the JPEG decodes — a visible jank on slow networks. The backend already has the dimensions; client-side recomputation is a waste. |
|
||||
| Store full `width` / `height` columns | Not needed anywhere — consumers want the categorical answer. If a future feature needs exact dimensions, they can be added later without migrating existing rows. |
|
||||
| A separate `thumbnail_metadata` table | Two scalar nullable columns aren't worth a join. See ADR-004 — thumbnails are modeled as a cross-cutting aspect of `Document`, not a sub-domain. |
|
||||
| Derive page count from the existing PDF at render time on the frontend | Duplicates work already done on the backend and requires a separate byte-range fetch of the PDF header. Frontend already gets `pageCount` "for free" via the Document response. |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- `ConversationThumbnail.svelte` picks the tile dimensions from `thumbnailAspect` directly — no async measurement, no layout shift.
|
||||
- `ThumbnailRow` reads `pageCount` synchronously for the badge. Multi-page letters are distinguishable at first paint.
|
||||
- Backfill runs the same migration path for every old document — re-executing generates the aspect + pageCount columns along with the JPEG, so operators don't have a second admin button to click.
|
||||
|
||||
**Harder:**
|
||||
- Both columns are `null` for every document until the backfill runs on a given instance. Frontend components guard with `?? 'PORTRAIT'` / `?? 1` so the UI stays sensible during the rollout window. The backfill is idempotent and cheap (reuses existing S3 object), so re-running it is the simplest recovery path.
|
||||
- The aspect threshold is a single constant in Java. A future need to tune per-type (e.g. postcards vs photos) means a code change, not a configuration change — acceptable for a single-operator archive.
|
||||
|
||||
### Ordering inside `ThumbnailService.generate()`
|
||||
|
||||
Aspect computation happens AFTER the JPEG upload succeeds but BEFORE the entity save — if the save throws, the columns rewind with it. Page count is captured while the `PDDocument` is still open; the `SourcePreview` record carries both the rendered first-page image and the page count back to the top of the pipeline so the PDF isn't reopened later.
|
||||
|
||||
## Future Direction
|
||||
|
||||
- If a postcard-specific "photo" chip is ever reintroduced, reuse `thumbnailAspect === 'LANDSCAPE' && pageCount === 1` rather than adding a new `kind` column.
|
||||
- If multi-size thumbnails are introduced (per ADR-004's future note), the aspect + pageCount are per-document and do not need to be duplicated per size.
|
||||
195
docs/specs/briefwechsel-fill/01-rich-rows.html
Normal file
195
docs/specs/briefwechsel-fill/01-rich-rows.html
Normal file
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Spec 1 — Rich Rows · Briefwechsel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="_shared.css">
|
||||
<style>
|
||||
/* Spec 1 specific */
|
||||
.rlist{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
|
||||
.row{display:grid;grid-template-columns:20px minmax(0,1fr) auto;column-gap:12px;align-items:stretch;padding:14px 18px;border-bottom:1px solid var(--line-2);border-left:3px solid transparent;cursor:pointer;transition:background .1s}
|
||||
.row:hover{background:var(--muted)}
|
||||
.row:last-child{border-bottom:0}
|
||||
.row.out{border-left-color:var(--primary)}
|
||||
.row.in{border-left-color:var(--accent)}
|
||||
.row-arrow{align-self:center;font-size:14px;opacity:.55;display:flex;justify-content:center}
|
||||
.row-body{min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||
.row-title{font-family:'Merriweather',serif;font-size:15px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.row-summary{font-size:12.5px;color:#555;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90%}
|
||||
.row-meta{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11.5px;color:var(--ink-3);align-items:center}
|
||||
.row-meta .sep{color:#bbb}
|
||||
.row-meta .ico{width:12px;height:12px;opacity:.55;display:inline-flex;align-items:center;justify-content:center}
|
||||
.row-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
|
||||
.row-right{display:flex;flex-direction:column;align-items:flex-end;justify-content:center;gap:4px;min-width:130px;padding-left:16px;border-left:1px dashed var(--line)}
|
||||
.row-archive{font-size:10px;font-weight:800;letter-spacing:.8px;color:#888;text-transform:uppercase;background:#F4F1EA;padding:3px 8px;border-radius:2px}
|
||||
.row-archive small{display:block;font-weight:600;color:#aaa;margin-top:1px;text-transform:none;letter-spacing:0;font-size:9.5px}
|
||||
@media (max-width: 900px){ .row-right{display:none} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="spec-meta">
|
||||
<div class="spec-meta-inner">
|
||||
<div>
|
||||
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
|
||||
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
|
||||
</div>
|
||||
<div class="spec-meta-right">
|
||||
<div><strong>Concept</strong>Rich Rows</div>
|
||||
<div><strong>Spec</strong>1 / 5</div>
|
||||
<div><strong>Effort</strong>Small — no new backend data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="spec-nav">
|
||||
<div class="spec-nav-inner">
|
||||
<span class="lbl">Specs</span>
|
||||
<a href="index.html">Overview</a>
|
||||
<a class="on" href="01-rich-rows.html">1 · Rich Rows</a>
|
||||
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
|
||||
<a href="03-master-detail.html">3 · Master-Detail Split</a>
|
||||
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
|
||||
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="page-wrap">
|
||||
|
||||
<!-- Real Familienarchiv chrome -->
|
||||
<div class="hdr">
|
||||
<div class="hdr-logo">FAMILIENARCHIV</div>
|
||||
<div class="hdr-nav">
|
||||
<a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a>
|
||||
</div>
|
||||
<div class="hdr-right">
|
||||
<div class="hdr-upload">⬆ UPLOAD</div>
|
||||
<span>DE · EN · ES</span>
|
||||
<div class="hdr-avatar">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="concept-intro">
|
||||
<h2>Concept 1 · Rich Rows — pack more metadata into each row</h2>
|
||||
No visuals, no structural change. Each row grows from a single line to a layered block: title (serif), summary (italic), meta row with icons, tag chips, and a right-hand column with archive box, script type and status.
|
||||
<div><span class="gain">✚ Zero backend changes</span><span class="gain">✚ Still one scrollable list</span><span class="cost">− Heavier rows; 10-row view becomes ~6–7 rows</span><span class="cost">− Empty-looking when a doc has no summary/tags</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Filter card (same as production) -->
|
||||
<div class="card">
|
||||
<div class="filter-row">
|
||||
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
|
||||
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="btn">Newest ↓</div>
|
||||
<div class="btn">▾ Filter</div>
|
||||
<div class="count"><b>851</b> Briefe</div>
|
||||
</div>
|
||||
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
|
||||
</div>
|
||||
|
||||
<div class="rlist">
|
||||
<div class="year-divider"><span class="y">1940</span><span class="n">1 Brief</span></div>
|
||||
|
||||
<div class="row in">
|
||||
<div class="row-arrow">←</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">Demo leserlicher Brief</div>
|
||||
<div class="row-summary">„letzte Lebenstage von W. Dörpfeld in Griechenland"</div>
|
||||
<div class="row-meta"><span>31. Mai 1940</span><span class="sep">·</span><span>📍 Belgard</span><span class="sep">·</span><span>von <b>Gertrud von Rofden</b></span></div>
|
||||
<div class="row-tags"><span class="tag">Dörpfeld</span><span class="tag">Griechenland</span><span class="tag muted">privat</span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-archive">Kasten VII · Mappe 5</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="year-divider"><span class="y">1923</span><span class="n">5 Briefe</span></div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0397 – 2. September 1923 – B.Lichterfelde</div>
|
||||
<div class="row-summary">„von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte"</div>
|
||||
<div class="row-meta"><span>2. September 1923</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
|
||||
<div class="row-tags"><span class="tag">Verlag</span><span class="tag">Familie</span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-archive">Kasten VI · Mappe 7</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0396 – 2. September 1923 – B.Lichterfelde</div>
|
||||
<div class="row-summary">—</div>
|
||||
<div class="row-meta"><span>2. September 1923</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-archive">Kasten VI · Mappe 7</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0524 – 31. Juli 1923 – Berlin</div>
|
||||
<div class="row-summary">„Glückwunsch zum 60. Geburtstag, Bericht über den Verlag"</div>
|
||||
<div class="row-meta"><span>31. Juli 1923</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
|
||||
<div class="row-tags"><span class="tag">Geburtstag</span><span class="tag">Verlag</span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-archive">Kasten VI · Mappe 7</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="year-divider"><span class="y">1922</span><span class="n">37 Briefe</span></div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0521 – 24. Dezember 1922 – Berlin</div>
|
||||
<div class="row-summary">„Weihnachtsbrief, Erinnerungen an das Jahr und Bitte um ein Bild der Kinder"</div>
|
||||
<div class="row-meta"><span>24. Dezember 1922</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
|
||||
<div class="row-tags"><span class="tag">Weihnachten</span><span class="tag">Familie</span><span class="tag muted">persönlich</span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-archive">Kasten V · Mappe 3</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0392 – 23. November 1921 – Bad Kissingen</div>
|
||||
<div class="row-summary">„Kurbericht, Gesundheitsupdate, Grüße an die Familie Cram"</div>
|
||||
<div class="row-meta"><span>23. November 1921</span><span class="sep">·</span><span>📍 Bad Kissingen</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
|
||||
<div class="row-tags"><span class="tag">Kuraufenthalt</span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-archive">Kasten V · Mappe 1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0501 – 13. Dezember 1920 – Berlin</div>
|
||||
<div class="row-summary">—</div>
|
||||
<div class="row-meta"><span>13. Dezember 1920</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-archive">Kasten IV · Mappe 8</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
391
docs/specs/briefwechsel-fill/02-thumbnail-rows.html
Normal file
391
docs/specs/briefwechsel-fill/02-thumbnail-rows.html
Normal file
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Spec 2 — Thumbnail Rows · Briefwechsel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="_shared.css">
|
||||
<style>
|
||||
/* Spec 2 v2 — bigger thumbnails, postcard support, bilateral distribution bar */
|
||||
.rlist{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
|
||||
|
||||
.row{display:grid;grid-template-columns:104px 1fr auto;column-gap:20px;align-items:center;padding:14px 20px;border-bottom:1px solid var(--line-2);border-left:3px solid transparent;cursor:pointer;transition:background .12s,box-shadow .12s}
|
||||
.row:hover{background:var(--muted)}
|
||||
.row:hover .row-thumb .thumb{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.1),inset 0 0 0 1px #fff}
|
||||
.row:last-child{border-bottom:0}
|
||||
.row.out{border-left-color:var(--primary)}
|
||||
.row.in{border-left-color:var(--accent)}
|
||||
|
||||
/* Thumbnail wrapper — fixed 104×104 cell, thumb centered */
|
||||
.row-thumb{width:104px;height:120px;display:flex;align-items:center;justify-content:center;position:relative}
|
||||
.thumb{transition:transform .12s,box-shadow .12s;box-shadow:0 1px 3px rgba(0,0,0,.08),inset 0 0 0 1px #fff}
|
||||
.thumb.portrait{width:82px;height:106px}
|
||||
.thumb.landscape{width:104px;height:72px}
|
||||
.thumb.postcard{width:104px;height:66px}
|
||||
.thumb-badge{position:absolute;top:2px;right:0;background:var(--brand-navy);color:#fff;font-size:9px;font-weight:800;padding:2px 6px;border-radius:10px;box-shadow:0 0 0 2px #fff}
|
||||
|
||||
/* Subtle paper variations for natural feel */
|
||||
.thumb.paper-1{background:linear-gradient(180deg,#fdfcf7 0%,#f4efdf 100%)}
|
||||
.thumb.paper-2{background:linear-gradient(180deg,#fefdf8 0%,#eee8d3 100%)}
|
||||
.thumb.paper-3{background:linear-gradient(180deg,#fbf8ed 0%,#efe7cb 100%)}
|
||||
.thumb.paper-4{background:linear-gradient(180deg,#fdfcf5 0%,#f0e9d5 100%)}
|
||||
|
||||
/* Kurrent-style handwriting — denser, angled */
|
||||
.thumb.kurrent .thumb-lines{padding:14% 9%;gap:3.5px}
|
||||
.thumb.kurrent .thumb-lines i{height:1.3px;background:rgba(24,40,70,.45);transform:rotate(-.5deg)}
|
||||
.thumb.kurrent .thumb-lines i:nth-child(3n){width:65%}
|
||||
.thumb.kurrent .thumb-lines i:nth-child(4n){width:92%}
|
||||
.thumb.kurrent .thumb-lines i:nth-child(5n){width:48%;transform:rotate(.4deg)}
|
||||
|
||||
/* Typewriter — regular, crisp */
|
||||
.thumb.typed .thumb-lines{padding:16% 12%;gap:2.5px}
|
||||
.thumb.typed .thumb-lines i{height:1px;background:rgba(40,40,40,.45)}
|
||||
.thumb.typed .thumb-lines i:nth-child(odd){width:93%}
|
||||
.thumb.typed .thumb-lines i:nth-child(even){width:88%}
|
||||
.thumb.typed .thumb-lines i:nth-child(7n){width:45%}
|
||||
|
||||
/* Postcard — stamp corner + postmark + short address lines */
|
||||
.thumb.postcard .thumb-lines{padding:10% 10% 14% 10%;gap:4px}
|
||||
.thumb.postcard .thumb-lines i{height:1.1px;background:rgba(24,40,70,.45)}
|
||||
.thumb.postcard .thumb-lines i:nth-child(1){width:60%}
|
||||
.thumb.postcard .thumb-lines i:nth-child(2){width:45%}
|
||||
.thumb.postcard .thumb-lines i:nth-child(3){width:70%}
|
||||
.thumb.postcard .thumb-lines i:nth-child(4){width:40%}
|
||||
.thumb.postcard::after{content:'';position:absolute;top:6px;right:6px;width:16px;height:18px;background:linear-gradient(135deg,#b6c9d3,#8ba9b6);border:1px dashed rgba(0,0,0,.15);box-shadow:0 0 0 1px #fff}
|
||||
.thumb.postcard::before{content:'';position:absolute;top:10px;right:26px;width:14px;height:14px;border:1.5px solid rgba(150,30,30,.4);border-radius:50%;background:radial-gradient(circle,rgba(150,30,30,.1) 40%,transparent 60%)}
|
||||
|
||||
/* Letter heading (typed with date/address at top) */
|
||||
.thumb.typed::before{content:'';position:absolute;top:10%;left:12%;right:12%;height:2px;background:transparent;border-bottom:1.5px solid rgba(40,40,40,.35)}
|
||||
|
||||
.row-body{min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||
.row-title{font-family:'Merriweather',serif;font-size:16px;font-weight:700;color:var(--ink);line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.row-summary{font-family:'Merriweather',serif;font-size:14px;color:#444;line-height:1.55;font-style:italic;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.row-summary::before{content:'„';color:var(--brand-mint);font-size:22px;font-weight:700;line-height:0;position:relative;top:6px;margin-right:2px}
|
||||
.row-summary::after{content:'”';color:var(--brand-mint);font-size:22px;font-weight:700;line-height:0;position:relative;top:6px;margin-left:2px}
|
||||
.row-meta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--ink-3);align-items:center;margin-top:2px}
|
||||
.row-meta .sep{color:#ccc}
|
||||
.row-meta .dir-ch{color:var(--primary);font-weight:800;font-size:13px}
|
||||
.row-meta .dir-ch.in{color:var(--accent)}
|
||||
.row-meta .kind-chip{display:inline-flex;align-items:center;gap:3px;background:#F4F1EA;color:#666;font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;letter-spacing:.3px;text-transform:uppercase}
|
||||
.row-tags{display:flex;gap:4px;flex-wrap:wrap}
|
||||
|
||||
.row-right{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
||||
.row-date{font-family:'Merriweather',serif;font-size:14px;color:#444;white-space:nowrap;font-weight:700}
|
||||
.row-date-rel{font-size:10.5px;color:#aaa;font-weight:600;letter-spacing:.3px}
|
||||
|
||||
/* Bilateral distribution bar — lifted from production ConversationTimeline */
|
||||
.distbar{display:flex;flex-direction:column;gap:6px;background:var(--muted);border:1px solid var(--line);border-bottom:0;padding:12px 20px}
|
||||
.distbar-labels{display:flex;justify-content:space-between;font-size:13px;font-weight:700}
|
||||
.distbar-labels .out{color:var(--primary);display:inline-flex;align-items:center;gap:6px}
|
||||
.distbar-labels .in{color:var(--accent);display:inline-flex;align-items:center;gap:6px}
|
||||
.distbar-labels .cnt{font-variant-numeric:tabular-nums}
|
||||
.distbar-bar{height:6px;display:flex;border-radius:3px;overflow:hidden;background:var(--line)}
|
||||
.distbar-bar .out{background:var(--primary)}
|
||||
.distbar-bar .in{background:var(--accent)}
|
||||
.distbar + .rlist{border-radius:0 0 2px 2px}
|
||||
|
||||
/* Section headings within the spec */
|
||||
.example-h{font-family:'Merriweather',serif;font-size:18px;color:var(--brand-navy);margin:36px 0 10px;padding-top:24px;border-top:1px dashed var(--line);font-weight:700;display:flex;align-items:baseline;gap:10px}
|
||||
.example-h .lbl{font-family:'Montserrat',sans-serif;font-size:10px;font-weight:800;color:#888;letter-spacing:1px;text-transform:uppercase}
|
||||
.example-h:first-of-type{border-top:0;padding-top:0;margin-top:20px}
|
||||
.example-sub{font-size:12px;color:#777;margin-bottom:14px;line-height:1.55}
|
||||
|
||||
/* Swap-buttons and filter chrome for bilateral filter card */
|
||||
.swap-inline{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid #C8C4BE;border-radius:50%;background:#F0EDE8;font-size:13px;color:var(--brand-navy);margin:0 -12px;position:relative;z-index:1}
|
||||
|
||||
@media (max-width: 760px){
|
||||
.row{grid-template-columns:82px 1fr;column-gap:14px}
|
||||
.row-right{grid-column:2;align-items:flex-start;margin-top:4px}
|
||||
.thumb.portrait{width:72px;height:94px}
|
||||
.thumb.landscape, .thumb.postcard{width:82px;height:58px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="spec-meta">
|
||||
<div class="spec-meta-inner">
|
||||
<div>
|
||||
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
|
||||
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
|
||||
</div>
|
||||
<div class="spec-meta-right">
|
||||
<div><strong>Concept</strong>Thumbnail Rows <span style="color:var(--brand-mint);margin-left:6px">v2</span></div>
|
||||
<div><strong>Spec</strong>2 / 5</div>
|
||||
<div><strong>Effort</strong>Medium — needs PDF thumbnail service</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="spec-nav">
|
||||
<div class="spec-nav-inner">
|
||||
<span class="lbl">Specs</span>
|
||||
<a href="index.html">Overview</a>
|
||||
<a href="01-rich-rows.html">1 · Rich Rows</a>
|
||||
<a class="on" href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
|
||||
<a href="03-master-detail.html">3 · Master-Detail Split</a>
|
||||
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
|
||||
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="page-wrap">
|
||||
|
||||
<div class="hdr">
|
||||
<div class="hdr-logo">FAMILIENARCHIV</div>
|
||||
<div class="hdr-nav"><a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a></div>
|
||||
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="concept-intro">
|
||||
<h2>Concept 2 · Thumbnail Rows — discovery through visual + summary</h2>
|
||||
/briefwechsel is for fun discovery, not dense scanning. The row gets a bigger first-page thumbnail (portrait for letters, landscape for postcards); the <b>summary</b> reads like a quote next to it; the right column stays calm — just the date. Rows without a summary remain clean and uncrowded.
|
||||
<div><span class="gain">✚ Visual recognition — letters and postcards look like what they are</span><span class="gain">✚ Summary reads as a quote, invites opening the letter</span><span class="gain">✚ Distribution bar gives the bilateral pair its own identity</span><span class="cost">− Depends on the PDF-thumbnail service (open issue)</span></div>
|
||||
</div>
|
||||
|
||||
<!-- ───────── Example 1 · single person ───────── -->
|
||||
<div class="example-h">Beispiel 1 <span class="lbl">alle Briefe von Walter de Gruyter · 851</span></div>
|
||||
<div class="example-sub">Single-sender case: sender is filled, correspondent is open. Direction arrows tell sent vs received.</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="filter-row">
|
||||
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
|
||||
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
|
||||
<div class="count"><b>851</b> Briefe</div>
|
||||
</div>
|
||||
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
|
||||
</div>
|
||||
|
||||
<div class="rlist">
|
||||
<div class="year-divider"><span class="y">1940</span><span class="n">1 Brief</span></div>
|
||||
|
||||
<div class="row in">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb portrait typed paper-1">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">Demo leserlicher Brief</div>
|
||||
<div class="row-summary">letzte Lebenstage von W. Dörpfeld in Griechenland — ausführlicher Bericht aus Belgard mit persönlichen Anmerkungen</div>
|
||||
<div class="row-meta"><span class="dir-ch in">← eingehend</span><span>Gertrud von Rofden</span><span class="sep">·</span><span>📍 Belgard</span><span class="sep">·</span><span class="row-tags"><span class="tag">Dörpfeld</span><span class="tag">Griechenland</span></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">31. Mai 1940</div>
|
||||
<div class="row-date-rel">vor 85 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="year-divider"><span class="y">1923</span><span class="n">5 Briefe</span></div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb portrait kurrent paper-2">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0397 – 2. September 1923 – B.Lichterfelde</div>
|
||||
<div class="row-summary">von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte — Notiz auf der Rückseite mit Korrekturen</div>
|
||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span><span class="tag">Familie</span></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">2. September 1923</div>
|
||||
<div class="row-date-rel">vor 102 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Postcard example -->
|
||||
<div class="row out">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb postcard kurrent paper-4">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">Ansichtskarte – 2. September 1923 – B.Lichterfelde</div>
|
||||
<div class="row-summary">kurze Grüße aus B.Lichterfelde, Hinweis auf den kommenden Besuch</div>
|
||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="kind-chip">✉ Postkarte</span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">2. September 1923</div>
|
||||
<div class="row-date-rel">vor 102 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-page letter -->
|
||||
<div class="row out">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb portrait kurrent paper-3">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
<span class="thumb-badge">4 S.</span>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0524 – 31. Juli 1923 – Berlin</div>
|
||||
<div class="row-summary">Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den anstehenden Umzug nach B.Lichterfelde im kommenden Herbst</div>
|
||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Walter Dieckmann</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span class="row-tags"><span class="tag">Geburtstag</span><span class="tag">Verlag</span></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">31. Juli 1923</div>
|
||||
<div class="row-date-rel">vor 102 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Without summary — still clean -->
|
||||
<div class="row out">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb portrait kurrent paper-1">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0396 – 2. September 1923 – B.Lichterfelde</div>
|
||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">2. September 1923</div>
|
||||
<div class="row-date-rel">vor 102 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="year-divider"><span class="y">1922</span><span class="n">37 Briefe</span></div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb portrait kurrent paper-2">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0521 – 24. Dezember 1922 – Berlin</div>
|
||||
<div class="row-summary">Weihnachtsbrief, Erinnerungen an das Jahr und Bitte um ein Bild der Kinder zum Christfest</div>
|
||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Walter Dieckmann</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span class="row-tags"><span class="tag">Weihnachten</span><span class="tag">Familie</span></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">24. Dezember 1922</div>
|
||||
<div class="row-date-rel">vor 103 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ───────── Example 2 · bilateral ───────── -->
|
||||
<div class="example-h">Beispiel 2 <span class="lbl">Briefwechsel Walter ↔ Herbert · 143</span></div>
|
||||
<div class="example-sub">Bilateral case: both filters are set. The distribution bar above the list shows how the correspondence is split — instantly visible who wrote more.</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="filter-row">
|
||||
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
|
||||
<div><div class="fl">Korrespondent</div><div class="fi">Herbert Cram</div></div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="btn">⇄ Tauschen</div>
|
||||
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
|
||||
<div class="count"><b>143</b> Briefe im Zeitraum</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="distbar" role="img" aria-label="Briefverteilung: 87 von Walter de Gruyter, 56 von Herbert Cram">
|
||||
<div class="distbar-labels">
|
||||
<span class="out"><span class="cnt">87</span> von Walter de Gruyter →</span>
|
||||
<span class="in">← <span class="cnt">56</span> von Herbert Cram</span>
|
||||
</div>
|
||||
<div class="distbar-bar">
|
||||
<span class="out" style="width:60.8%"></span>
|
||||
<span class="in" style="width:39.2%"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rlist" style="border-radius:0 0 2px 2px">
|
||||
<div class="year-divider"><span class="y">1923</span><span class="n">12 Briefe</span></div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb portrait kurrent paper-2">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0397 – 2. September 1923 – B.Lichterfelde</div>
|
||||
<div class="row-summary">von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte</div>
|
||||
<div class="row-meta"><span class="dir-ch">→</span><span>Walter an Herbert</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">2. September 1923</div>
|
||||
<div class="row-date-rel">vor 102 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row in">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb portrait kurrent paper-3">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">H-0213 – 29. August 1923 – Leipzig</div>
|
||||
<div class="row-summary">Antwort auf Walters Anfrage zur Herbstauslieferung, Bitte um Rückmeldung bis Monatsende</div>
|
||||
<div class="row-meta"><span class="dir-ch in">←</span><span>Herbert an Walter</span><span class="sep">·</span><span>📍 Leipzig</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">29. August 1923</div>
|
||||
<div class="row-date-rel">vor 102 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row in">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb postcard kurrent paper-4">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">Ansichtskarte – 20. August 1923 – Thüringer Wald</div>
|
||||
<div class="row-summary">Urlaubsgruß, kurze Notiz über Wetter und geplante Rückkehr</div>
|
||||
<div class="row-meta"><span class="dir-ch in">←</span><span>Herbert an Walter</span><span class="sep">·</span><span>📍 Thüringer Wald</span><span class="sep">·</span><span class="kind-chip">✉ Postkarte</span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">20. August 1923</div>
|
||||
<div class="row-date-rel">vor 102 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-thumb">
|
||||
<div class="thumb portrait kurrent paper-1">
|
||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
<span class="thumb-badge">3 S.</span>
|
||||
</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0392 – 23. November 1921 – Bad Kissingen</div>
|
||||
<div class="row-summary">Kurbericht aus Bad Kissingen, Gesundheitsupdate nach der ersten Woche, Grüße an die Familie Cram</div>
|
||||
<div class="row-meta"><span class="dir-ch">→</span><span>Walter an Herbert</span><span class="sep">·</span><span>📍 Bad Kissingen</span><span class="sep">·</span><span class="row-tags"><span class="tag">Kuraufenthalt</span></span></div>
|
||||
</div>
|
||||
<div class="row-right">
|
||||
<div class="row-date">23. November 1921</div>
|
||||
<div class="row-date-rel">vor 104 Jahren</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes footer -->
|
||||
<div style="margin-top:32px;padding:16px 20px;background:#fff;border-left:4px solid var(--brand-navy);font-size:13px;color:#333;line-height:1.7">
|
||||
<b style="color:var(--brand-navy)">Details:</b>
|
||||
<ul style="margin:8px 0 0 20px;padding:0">
|
||||
<li><b>Thumbnail</b> — 82×106 for portrait, 104×72 for landscape/postcards. Postcards also get a stamp + postmark corner. Kurrent handwriting rendered with slight line skew; typewriter rendered with clean parallel lines. Multi-page letters get a "<code>4 S.</code>" badge.</li>
|
||||
<li><b>Summary</b> — shown in serif italic with colored quote marks. Reads like a quote from the letter. If empty, the row simply omits the line — no apologetic placeholder.</li>
|
||||
<li><b>Right column</b> — date only, in serif. We dropped archive box (only meaningful for one family archive) and any lookup metadata. The right column stays calm on purpose.</li>
|
||||
<li><b>Distribution bar</b> — appears only in bilateral mode (both sender and receiver set). Pattern lifted from the existing <code>ConversationTimeline</code> so it's familiar.</li>
|
||||
<li><b>Mobile</b> — thumbnail shrinks (72×94 portrait / 82×58 landscape) and the right column wraps under the body.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
222
docs/specs/briefwechsel-fill/03-master-detail.html
Normal file
222
docs/specs/briefwechsel-fill/03-master-detail.html
Normal file
@@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Spec 3 — Master-Detail · Briefwechsel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="_shared.css">
|
||||
<style>
|
||||
/* Spec 3 specific */
|
||||
.split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);gap:24px;align-items:start}
|
||||
@media (max-width: 1100px){ .split{grid-template-columns:1fr} .preview-col{position:static !important} }
|
||||
|
||||
/* List (compact) */
|
||||
.rlist{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
|
||||
.row{display:grid;grid-template-columns:16px 1fr auto;column-gap:10px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line-2);border-left:3px solid transparent;cursor:pointer}
|
||||
.row:hover{background:var(--muted)}
|
||||
.row.out{border-left-color:var(--primary)}
|
||||
.row.in{border-left-color:var(--accent)}
|
||||
.row.sel{background:#e7f4f3;border-left-color:var(--brand-mint);box-shadow:inset 2px 0 0 var(--accent)}
|
||||
.row-arrow{font-size:13px;opacity:.55}
|
||||
.row-body{min-width:0}
|
||||
.row-title{font-family:'Merriweather',serif;font-size:13.5px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:2px}
|
||||
.row-sub{font-size:11px;color:var(--ink-3);display:flex;gap:6px;align-items:center}
|
||||
.row-sub .sep{color:#ccc}
|
||||
.row-right{font-size:11px;color:var(--ink-3);display:flex;align-items:center;gap:6px}
|
||||
|
||||
/* Preview panel */
|
||||
.preview-col{position:sticky;top:20px}
|
||||
.preview{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.04)}
|
||||
.prev-hdr{padding:16px 20px;background:#fafaf5;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
||||
.prev-hdr-left .prev-kind{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:var(--accent);margin-bottom:4px}
|
||||
.prev-hdr-left h2{font-family:'Merriweather',serif;font-size:18px;color:var(--brand-navy);line-height:1.35}
|
||||
.prev-hdr .close{font-size:18px;color:#999;cursor:pointer}
|
||||
.prev-body{display:grid;grid-template-columns:200px 1fr;gap:20px;padding:20px}
|
||||
.prev-thumb{width:200px;height:260px;flex-shrink:0}
|
||||
.prev-thumb .thumb{width:100%;height:100%}
|
||||
.prev-meta{display:flex;flex-direction:column;gap:14px}
|
||||
.mkv{display:grid;grid-template-columns:92px 1fr;row-gap:6px;column-gap:10px;font-size:12px;color:#444}
|
||||
.mkv .k{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:#888;padding-top:2px}
|
||||
.mkv .v{color:var(--ink)}
|
||||
.mkv .v b{color:var(--brand-navy)}
|
||||
.prev-summary{background:#fbfaf5;border-left:3px solid var(--brand-mint);padding:10px 14px;font-family:'Merriweather',serif;font-size:13px;color:#333;line-height:1.65;font-style:italic}
|
||||
.prev-tags{display:flex;flex-wrap:wrap;gap:5px}
|
||||
.prev-excerpt{padding:0 20px 18px;font-family:'Merriweather',serif;font-size:13px;color:#444;line-height:1.75;border-top:1px dashed var(--line);padding-top:18px}
|
||||
.prev-excerpt .lbl{font-family:'Montserrat',sans-serif;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:8px;display:block;font-style:normal}
|
||||
.prev-actions{border-top:1px solid var(--line);padding:12px 20px;background:#fafaf5;display:flex;gap:8px;justify-content:flex-end}
|
||||
.prev-actions .btn.primary{background:var(--brand-navy);color:#fff;border-color:var(--brand-navy)}
|
||||
.prev-hint{padding:14px 20px;background:#f7f5f2;color:#777;font-size:11.5px;border-top:1px dashed var(--line);font-style:italic}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="spec-meta">
|
||||
<div class="spec-meta-inner">
|
||||
<div>
|
||||
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
|
||||
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
|
||||
</div>
|
||||
<div class="spec-meta-right">
|
||||
<div><strong>Concept</strong>Master-Detail Split</div>
|
||||
<div><strong>Spec</strong>3 / 5</div>
|
||||
<div><strong>Effort</strong>Medium — requires selection state</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="spec-nav">
|
||||
<div class="spec-nav-inner">
|
||||
<span class="lbl">Specs</span>
|
||||
<a href="index.html">Overview</a>
|
||||
<a href="01-rich-rows.html">1 · Rich Rows</a>
|
||||
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
|
||||
<a class="on" href="03-master-detail.html">3 · Master-Detail Split</a>
|
||||
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
|
||||
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="page-wrap">
|
||||
|
||||
<div class="hdr">
|
||||
<div class="hdr-logo">FAMILIENARCHIV</div>
|
||||
<div class="hdr-nav"><a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a></div>
|
||||
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="concept-intro">
|
||||
<h2>Concept 3 · Master-Detail Split — list on left, preview panel on right</h2>
|
||||
Keep the list compact (it stays scannable). Add a persistent right-hand panel that fills the empty space with a preview of the selected letter — thumbnail, metadata, summary, and a transcription excerpt when available. Click a row to swap its content; "Open" button still navigates to the full document page.
|
||||
<div><span class="gain">✚ Users browse 10× more letters per session (no back-nav)</span><span class="gain">✚ Right column is always doing work</span><span class="cost">− On narrow viewports collapses to stacked</span><span class="cost">− Mobile pattern differs — needs sheet/drawer</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Filter card -->
|
||||
<div class="card">
|
||||
<div class="filter-row">
|
||||
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
|
||||
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
|
||||
<div class="count"><b>851</b> Briefe</div>
|
||||
</div>
|
||||
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<!-- Master list -->
|
||||
<div class="rlist">
|
||||
<div class="year-divider"><span class="y">1940</span><span class="n">1 Brief</span></div>
|
||||
<div class="row in">
|
||||
<div class="row-arrow">←</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">Demo leserlicher Brief</div>
|
||||
<div class="row-sub"><span>31. Mai 1940</span><span class="sep">·</span><span>Belgard</span><span class="sep">·</span><span>Gertrud von Rofden</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="year-divider"><span class="y">1923</span><span class="n">5 Briefe</span></div>
|
||||
|
||||
<div class="row out sel">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0397 – 2. September 1923 – B.Lichterfelde</div>
|
||||
<div class="row-sub"><span>2. Sep 1923</span><span class="sep">·</span><span>B.Lichterfelde</span><span class="sep">·</span><span>Herbert Cram</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0396 – 2. September 1923 – B.Lichterfelde</div>
|
||||
<div class="row-sub"><span>2. Sep 1923</span><span class="sep">·</span><span>B.Lichterfelde</span><span class="sep">·</span><span>Herbert Cram</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0524 – 31. Juli 1923 – Berlin</div>
|
||||
<div class="row-sub"><span>31. Juli 1923</span><span class="sep">·</span><span>Berlin</span><span class="sep">·</span><span>Walter Dieckmann</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0523 – 12. Mai 1923 – Berlin</div>
|
||||
<div class="row-sub"><span>12. Mai 1923</span><span class="sep">·</span><span>Berlin</span><span class="sep">·</span><span>Walter Dieckmann</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0522 – 7. März 1923 – Lindau Bodensee</div>
|
||||
<div class="row-sub"><span>7. März 1923</span><span class="sep">·</span><span>Lindau Bodensee</span><span class="sep">·</span><span>Walter Dieckmann</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="year-divider"><span class="y">1922</span><span class="n">37 Briefe</span></div>
|
||||
|
||||
<div class="row out">
|
||||
<div class="row-arrow">→</div>
|
||||
<div class="row-body">
|
||||
<div class="row-title">W-0521 – 24. Dezember 1922 – Berlin</div>
|
||||
<div class="row-sub"><span>24. Dez 1922</span><span class="sep">·</span><span>Berlin</span><span class="sep">·</span><span>Walter Dieckmann</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail preview -->
|
||||
<div class="preview-col">
|
||||
<div class="preview">
|
||||
<div class="prev-hdr">
|
||||
<div class="prev-hdr-left">
|
||||
<div class="prev-kind">→ Ausgehend · an Herbert Cram</div>
|
||||
<h2>W-0397 – 2. September 1923 – B.Lichterfelde</h2>
|
||||
</div>
|
||||
<div class="close">✕</div>
|
||||
</div>
|
||||
<div class="prev-body">
|
||||
<div class="prev-thumb">
|
||||
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
</div>
|
||||
<div class="prev-meta">
|
||||
<div class="mkv">
|
||||
<div class="k">Datum</div><div class="v"><b>2. September 1923</b></div>
|
||||
<div class="k">Ort</div><div class="v">B.Lichterfelde</div>
|
||||
<div class="k">Absender</div><div class="v"><b>Walter de Gruyter</b></div>
|
||||
<div class="k">Empfänger</div><div class="v">Herbert Cram</div>
|
||||
<div class="k">Archiv</div><div class="v">Kasten VI · Mappe 7</div>
|
||||
</div>
|
||||
<div class="prev-summary">„von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte — Notiz auf der Rückseite mit Korrekturen zur Publikationsliste."</div>
|
||||
<div class="prev-tags"><span class="tag">Verlag</span><span class="tag">Familie</span><span class="tag muted">Korrespondenz</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prev-excerpt">
|
||||
<span class="lbl">Transkription (Auszug)</span>
|
||||
„Lieber Herbert, heute erreichte mich Dein Brief vom 29. August, und ich danke Dir herzlich für die ausführliche Schilderung. Die Angelegenheit mit dem Verlag soll am Montag abschließend besprochen werden — ich werde Dir sogleich Bescheid geben. In Belgard waren wir alle wohl, und die Kinder grüßen …"
|
||||
</div>
|
||||
<div class="prev-actions">
|
||||
<div class="btn">◎ In Briefwechsel öffnen</div>
|
||||
<div class="btn primary">↗ Vollständiges Dokument</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:11px;color:#888;padding:0 4px">Tipp: Pfeiltasten ↑↓ zum Durchblättern, Enter zum Öffnen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
227
docs/specs/briefwechsel-fill/04-gallery-cards.html
Normal file
227
docs/specs/briefwechsel-fill/04-gallery-cards.html
Normal file
@@ -0,0 +1,227 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Spec 4 — Gallery Cards · Briefwechsel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="_shared.css">
|
||||
<style>
|
||||
/* Spec 4 specific */
|
||||
.view-toggle{display:flex;gap:4px;background:#F0EDE5;padding:3px;border-radius:3px;margin-left:8px}
|
||||
.view-toggle span{padding:5px 10px;font-size:11px;font-weight:700;color:#888;cursor:pointer;border-radius:2px}
|
||||
.view-toggle span.on{background:#fff;color:var(--brand-navy);box-shadow:0 1px 2px rgba(0,0,0,.08)}
|
||||
|
||||
.year-band{font-family:'Merriweather',serif;font-size:28px;font-weight:900;color:var(--brand-navy);letter-spacing:-.5px;margin:24px 0 10px 2px;display:flex;align-items:baseline;gap:12px}
|
||||
.year-band .n{font-family:'Montserrat',sans-serif;font-size:12px;font-weight:700;color:#888;letter-spacing:0}
|
||||
|
||||
.grid{display:grid;grid-template-columns:repeat(4, 1fr);gap:18px}
|
||||
@media (max-width: 1280px){ .grid{grid-template-columns:repeat(3, 1fr)} }
|
||||
@media (max-width: 900px){ .grid{grid-template-columns:repeat(2, 1fr)} }
|
||||
|
||||
.card-doc{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden;cursor:pointer;transition:transform .12s,box-shadow .12s;display:flex;flex-direction:column}
|
||||
.card-doc:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.08)}
|
||||
.card-doc .thumb-wrap{position:relative;aspect-ratio:3/4;overflow:hidden;background:#f5f2ea;border-bottom:1px solid var(--line)}
|
||||
.card-doc .thumb{width:100%;height:100%}
|
||||
.card-doc .dir-badge{position:absolute;top:8px;left:8px;background:rgba(0,40,80,.9);color:#fff;font-size:10px;font-weight:800;padding:3px 8px;border-radius:10px;display:flex;align-items:center;gap:3px;letter-spacing:.3px}
|
||||
.card-doc .dir-badge.in{background:rgba(47,158,149,.9)}
|
||||
.card-doc .body{padding:12px 14px 14px;display:flex;flex-direction:column;gap:4px;flex:1}
|
||||
.card-doc .body h3{font-family:'Merriweather',serif;font-size:13px;font-weight:700;color:var(--brand-navy);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.card-doc .body .date{font-size:11px;color:var(--ink-3);font-weight:600}
|
||||
.card-doc .body .party{font-size:11px;color:var(--ink-3);font-style:italic;margin-top:2px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.card-doc .body .summary{font-size:11.5px;color:#666;font-family:'Merriweather',serif;font-style:italic;line-height:1.45;margin-top:6px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.card-doc .body .tags{margin-top:auto;padding-top:10px;display:flex;gap:4px;flex-wrap:wrap}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="spec-meta">
|
||||
<div class="spec-meta-inner">
|
||||
<div>
|
||||
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
|
||||
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
|
||||
</div>
|
||||
<div class="spec-meta-right">
|
||||
<div><strong>Concept</strong>Gallery Cards</div>
|
||||
<div><strong>Spec</strong>4 / 5</div>
|
||||
<div><strong>Effort</strong>Large — structural change + thumbnail service</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="spec-nav">
|
||||
<div class="spec-nav-inner">
|
||||
<span class="lbl">Specs</span>
|
||||
<a href="index.html">Overview</a>
|
||||
<a href="01-rich-rows.html">1 · Rich Rows</a>
|
||||
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
|
||||
<a href="03-master-detail.html">3 · Master-Detail Split</a>
|
||||
<a class="on" href="04-gallery-cards.html">4 · Gallery Cards</a>
|
||||
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="page-wrap">
|
||||
|
||||
<div class="hdr">
|
||||
<div class="hdr-logo">FAMILIENARCHIV</div>
|
||||
<div class="hdr-nav"><a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a></div>
|
||||
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="concept-intro">
|
||||
<h2>Concept 4 · Gallery Cards — grid of letter cards, album style</h2>
|
||||
Replace the list with a 4-column (collapses to 3 → 2) grid. Each card is a miniature letter card with thumbnail, title, date, correspondent, and a 3-line summary. Year bands replace dividers. The page becomes a family-letter-album.
|
||||
<div><span class="gain">✚ Beautiful browsing for archive overview</span><span class="gain">✚ Most "filled" of all concepts — no empty space</span><span class="cost">− Chronological scanning is worse than lists</span><span class="cost">− 851 letters → ~213 rows in grid, long page or needs paging</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Filter card -->
|
||||
<div class="card">
|
||||
<div class="filter-row">
|
||||
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
|
||||
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
|
||||
<div class="view-toggle"><span>☰ Liste</span><span class="on">▦ Galerie</span></div>
|
||||
<div class="count"><b>851</b> Briefe</div>
|
||||
</div>
|
||||
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
|
||||
</div>
|
||||
|
||||
<div class="year-band">1940 <span class="n">1 Brief</span></div>
|
||||
<div class="grid">
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge in">← eingehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>Demo leserlicher Brief</h3>
|
||||
<div class="date">31. Mai 1940 · Belgard</div>
|
||||
<div class="party">von <b>Gertrud von Rofden</b></div>
|
||||
<div class="summary">„letzte Lebenstage von W. Dörpfeld in Griechenland — ausführlicher Bericht"</div>
|
||||
<div class="tags"><span class="tag">Dörpfeld</span><span class="tag">Griechenland</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="year-band">1923 <span class="n">5 Briefe</span></div>
|
||||
<div class="grid">
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge">→ ausgehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>W-0397 – 2. September 1923 – B.Lichterfelde</h3>
|
||||
<div class="date">2. September 1923 · B.Lichterfelde</div>
|
||||
<div class="party">an <b>Herbert Cram</b></div>
|
||||
<div class="summary">„von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte"</div>
|
||||
<div class="tags"><span class="tag">Verlag</span><span class="tag">Familie</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge">→ ausgehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>W-0396 – 2. September 1923 – B.Lichterfelde</h3>
|
||||
<div class="date">2. September 1923 · B.Lichterfelde</div>
|
||||
<div class="party">an <b>Herbert Cram</b></div>
|
||||
<div class="tags"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge">→ ausgehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>W-0524 – 31. Juli 1923 – Berlin</h3>
|
||||
<div class="date">31. Juli 1923 · Berlin</div>
|
||||
<div class="party">an <b>Walter Dieckmann</b></div>
|
||||
<div class="summary">„Glückwunsch zum 60. Geburtstag, Bericht über den Verlag"</div>
|
||||
<div class="tags"><span class="tag">Geburtstag</span><span class="tag">Verlag</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge">→ ausgehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>W-0523 – 12. Mai 1923 – Berlin</h3>
|
||||
<div class="date">12. Mai 1923 · Berlin</div>
|
||||
<div class="party">an <b>Walter Dieckmann</b></div>
|
||||
<div class="summary">„Routinebericht des Verlagsgeschäfts, Rückfragen zu Auftragslage"</div>
|
||||
<div class="tags"><span class="tag">Verlag</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="year-band">1922 <span class="n">37 Briefe</span></div>
|
||||
<div class="grid">
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge">→ ausgehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>W-0521 – 24. Dezember 1922 – Berlin</h3>
|
||||
<div class="date">24. Dezember 1922 · Berlin</div>
|
||||
<div class="party">an <b>Walter Dieckmann</b></div>
|
||||
<div class="summary">„Weihnachtsbrief, Erinnerungen an das Jahr und Bitte um ein Bild der Kinder"</div>
|
||||
<div class="tags"><span class="tag">Weihnachten</span><span class="tag">Familie</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge">→ ausgehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>W-0392 – 23. Nov 1921 – Bad Kissingen</h3>
|
||||
<div class="date">23. November 1921 · Bad Kissingen</div>
|
||||
<div class="party">an <b>Herbert Cram</b></div>
|
||||
<div class="summary">„Kurbericht, Gesundheitsupdate, Grüße an die Familie"</div>
|
||||
<div class="tags"><span class="tag">Kuraufenthalt</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge">→ ausgehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>W-0501 – 13. Dez 1920 – Berlin</h3>
|
||||
<div class="date">13. Dezember 1920 · Berlin</div>
|
||||
<div class="party">an <b>Walter Dieckmann</b></div>
|
||||
<div class="tags"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-doc">
|
||||
<div class="thumb-wrap">
|
||||
<div class="thumb handwritten"><div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
|
||||
<span class="dir-badge">→ ausgehend</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>W-0484 – 27. Dez 1919 – Berlin</h3>
|
||||
<div class="date">27. Dezember 1919 · Berlin</div>
|
||||
<div class="party">an <b>Walter Dieckmann</b></div>
|
||||
<div class="summary">„Jahreswechselgrüße, kurzer Bericht über den Zustand des Verlags"</div>
|
||||
<div class="tags"><span class="tag">Neujahr</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
303
docs/specs/briefwechsel-fill/05-person-dashboard.html
Normal file
303
docs/specs/briefwechsel-fill/05-person-dashboard.html
Normal file
@@ -0,0 +1,303 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Spec 5 — Person Dashboard · Briefwechsel Insights</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="_shared.css">
|
||||
<style>
|
||||
/* Spec 5 specific */
|
||||
.person-grid{display:grid;grid-template-columns:35% 1fr;gap:32px;align-items:start}
|
||||
@media (max-width: 1100px){ .person-grid{grid-template-columns:1fr} }
|
||||
|
||||
/* Person card (left) */
|
||||
.pcard{background:#fff;border:1px solid var(--line);border-radius:2px;padding:24px;display:flex;flex-direction:column;align-items:center;gap:14px}
|
||||
.pavatar{width:100px;height:100px;border-radius:50%;background:var(--brand-mint);color:var(--brand-navy);display:flex;align-items:center;justify-content:center;font-size:36px;font-weight:900;font-family:'Merriweather',serif}
|
||||
.pname{font-family:'Merriweather',serif;font-size:22px;font-weight:700;color:var(--brand-navy);text-align:center;line-height:1.3}
|
||||
.pdates{font-size:12px;color:#888;font-weight:600;letter-spacing:.5px}
|
||||
.pnotes{font-size:13px;color:#555;line-height:1.6;margin-top:6px;padding-top:14px;border-top:1px dashed var(--line);width:100%;font-family:'Merriweather',serif;font-style:italic}
|
||||
.pactions{margin-top:14px;display:flex;gap:8px;width:100%}
|
||||
.pactions .btn{flex:1;justify-content:center;font-size:10px}
|
||||
.pactions .btn.primary{background:var(--brand-navy);color:#fff;border-color:var(--brand-navy)}
|
||||
|
||||
/* Name history card */
|
||||
.ncard{background:#fff;border:1px solid var(--line);border-radius:2px;padding:18px 20px;margin-top:20px}
|
||||
.ncard h3{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:10px}
|
||||
.ncard ul{list-style:none}
|
||||
.ncard li{padding:5px 0;font-size:12.5px;color:#444;border-top:1px dashed var(--line);display:flex;justify-content:space-between}
|
||||
.ncard li:first-child{border-top:0}
|
||||
|
||||
/* Right column */
|
||||
.dash{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
|
||||
.dash-hdr{background:var(--brand-navy);color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:center}
|
||||
.dash-hdr h2{font-family:'Merriweather',serif;font-size:16px;font-weight:700}
|
||||
.dash-hdr .open-conv{background:var(--brand-mint);color:var(--brand-navy);font-size:11px;font-weight:800;padding:6px 14px;border-radius:2px;text-transform:uppercase;letter-spacing:.6px;text-decoration:none}
|
||||
|
||||
/* Stat strip */
|
||||
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line-2);border-bottom:1px solid var(--line)}
|
||||
.stats div{background:#fafaf5;padding:14px 18px;text-align:center}
|
||||
.stats .v{font-family:'Merriweather',serif;font-size:22px;font-weight:900;color:var(--brand-navy);letter-spacing:-.5px}
|
||||
.stats .k{font-size:10px;color:#888;font-weight:700;text-transform:uppercase;letter-spacing:.6px;margin-top:2px}
|
||||
.stats .out{color:var(--primary)}
|
||||
.stats .in{color:var(--accent)}
|
||||
|
||||
/* Sections */
|
||||
.dsec{padding:18px 22px;border-top:1px solid var(--line-2)}
|
||||
.dsec:first-of-type{border-top:0}
|
||||
.dsec h3{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:12px;display:flex;justify-content:space-between;align-items:baseline}
|
||||
.dsec h3 .note{font-size:11px;color:#555;text-transform:none;letter-spacing:0;font-weight:600}
|
||||
|
||||
/* Activity histogram */
|
||||
.hist{display:flex;align-items:flex-end;gap:2px;height:90px;padding:4px 0 0}
|
||||
.hist .bar{flex:1;background:var(--brand-mint);opacity:.55;border-radius:1px 1px 0 0;position:relative;cursor:pointer;transition:opacity .12s}
|
||||
.hist .bar:hover{opacity:1}
|
||||
.hist .bar.peak{background:var(--brand-navy);opacity:.85}
|
||||
.hist-labels{display:flex;justify-content:space-between;font-size:10px;color:#888;margin-top:6px;font-weight:700}
|
||||
|
||||
/* Split bar direction */
|
||||
.dsplit{display:flex;justify-content:space-between;font-size:12px;font-weight:700;margin-bottom:8px}
|
||||
.dsplit .out{color:var(--primary)}
|
||||
.dsplit .in{color:var(--accent)}
|
||||
.dbar{height:10px;display:flex;border-radius:5px;overflow:hidden;background:#F0EDE5}
|
||||
.dbar .out{background:var(--brand-navy)}
|
||||
.dbar .in{background:var(--accent)}
|
||||
|
||||
/* Top list */
|
||||
.toplist{display:flex;flex-direction:column;gap:8px}
|
||||
.toplist .ti{display:flex;align-items:center;gap:10px;font-size:13px;padding:4px 6px;border-radius:2px;cursor:pointer}
|
||||
.toplist .ti:hover{background:var(--muted)}
|
||||
.toplist .ti .name{flex:1;color:var(--ink);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.toplist .ti .bar-wrap{width:120px;height:7px;background:#F0EDE5;border-radius:4px;overflow:hidden;flex-shrink:0}
|
||||
.toplist .ti .bar{height:100%;background:var(--brand-navy);border-radius:4px}
|
||||
.toplist .ti .val{width:38px;text-align:right;font-size:12px;color:#888;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.toplist .ti .dir{font-size:13px;width:16px;font-weight:800}
|
||||
.toplist .ti .dir.out{color:var(--primary)}
|
||||
.toplist .ti .dir.in{color:var(--accent)}
|
||||
.toplist .ti .dir.both{color:#888}
|
||||
|
||||
/* Cloud */
|
||||
.cloud{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.cloud .tag{cursor:pointer;padding:3px 10px;border-radius:12px;font-weight:700;transition:transform .1s}
|
||||
.cloud .tag:hover{transform:translateY(-1px)}
|
||||
.cloud .tag.s-xl{font-size:15px;padding:4px 12px}
|
||||
.cloud .tag.s-l{font-size:13px}
|
||||
.cloud .tag.s-m{font-size:12px}
|
||||
.cloud .tag.s-s{font-size:11px}
|
||||
|
||||
/* Two-col arrangement */
|
||||
.twocol{display:grid;grid-template-columns:1fr 1fr;gap:32px}
|
||||
@media (max-width: 900px){ .twocol{grid-template-columns:1fr} }
|
||||
|
||||
/* Existing doc lists below (compressed) */
|
||||
.doclist-card{background:#fff;border:1px solid var(--line);border-radius:2px;margin-top:20px;overflow:hidden}
|
||||
.doclist-card .dh{padding:14px 20px;border-bottom:1px solid var(--line-2);font-size:13px;font-weight:700;color:var(--brand-navy);display:flex;justify-content:space-between;align-items:center}
|
||||
.doclist-card .dh .cnt{font-size:11px;color:#888;font-weight:600}
|
||||
.doclist-card .di{padding:10px 20px;border-bottom:1px solid var(--line-2);display:flex;justify-content:space-between;align-items:center;font-size:12.5px}
|
||||
.doclist-card .di:last-child{border-bottom:0}
|
||||
.doclist-card .di .t{font-family:'Merriweather',serif;font-weight:700;color:var(--ink);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;margin-right:10px}
|
||||
.doclist-card .di .m{color:#888;font-size:11px;white-space:nowrap}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="spec-meta">
|
||||
<div class="spec-meta-inner">
|
||||
<div>
|
||||
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
|
||||
<p>Insights belong on the person detail page, not on the letter list. This spec mocks the dashboard that replaces the "empty feeling" problem at its proper home.</p>
|
||||
</div>
|
||||
<div class="spec-meta-right">
|
||||
<div><strong>Concept</strong>Person Dashboard</div>
|
||||
<div><strong>Spec</strong>5 / 5</div>
|
||||
<div><strong>Page</strong>/persons/[id] (not /briefwechsel)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="spec-nav">
|
||||
<div class="spec-nav-inner">
|
||||
<span class="lbl">Specs</span>
|
||||
<a href="index.html">Overview</a>
|
||||
<a href="01-rich-rows.html">1 · Rich Rows</a>
|
||||
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
|
||||
<a href="03-master-detail.html">3 · Master-Detail Split</a>
|
||||
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
|
||||
<a class="on" href="05-person-dashboard.html">5 · Person Dashboard</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="page-wrap">
|
||||
|
||||
<div class="hdr">
|
||||
<div class="hdr-logo">FAMILIENARCHIV</div>
|
||||
<div class="hdr-nav"><a>Documents</a><a class="on">Persons</a><a>Letters</a><a>Admin</a></div>
|
||||
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="concept-intro">
|
||||
<h2>Concept 5 · Person Dashboard — insights live on /persons/[id], not on /briefwechsel</h2>
|
||||
The /briefwechsel page stays focused on reading letters. The archive-level understanding (how much, with whom, when, about what) is moved to the person detail page, where it's useful in all contexts — not only when a correspondent is selected. A "Briefwechsel öffnen" button ties the two pages together.
|
||||
<div><span class="gain">✚ /briefwechsel stays calm and focused</span><span class="gain">✚ Dashboard is useful on every visit to a person, not just during letter review</span><span class="gain">✚ Each dashboard element links back into /briefwechsel with filters</span><span class="cost">− Separates the work over two pages — needs a clear handoff button</span></div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px;color:#888;margin-bottom:18px"><a href="#" style="color:#888;text-decoration:none">← Zurück</a></div>
|
||||
|
||||
<div class="person-grid">
|
||||
<!-- Left column: person identity -->
|
||||
<div>
|
||||
<div class="pcard">
|
||||
<div class="pavatar">WG</div>
|
||||
<div class="pname">Walter de Gruyter</div>
|
||||
<div class="pdates">1862 – 1923</div>
|
||||
<div class="pnotes">Verleger und Namensgeber des Verlags Walter de Gruyter. Langjährige Korrespondenz mit Familie Dieckmann und Herbert Cram rund um Verlag, Familie und Kuraufenthalte.</div>
|
||||
<div class="pactions">
|
||||
<a class="btn">◎ Bearbeiten</a>
|
||||
<a class="btn primary">↗ Briefwechsel öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ncard">
|
||||
<h3>Namensvarianten</h3>
|
||||
<ul>
|
||||
<li><span>Walter de Gruyter</span><span style="color:#888">Hauptname</span></li>
|
||||
<li><span>W. de Gruyter</span><span style="color:#888">Abkürzung</span></li>
|
||||
<li><span>Dr. Walter de Gruyter</span><span style="color:#888">mit Titel</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: dashboard -->
|
||||
<div>
|
||||
<div class="dash">
|
||||
<div class="dash-hdr">
|
||||
<h2>Korrespondenz-Überblick</h2>
|
||||
<a class="open-conv" href="#">↗ Briefwechsel öffnen</a>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div><div class="v">851</div><div class="k">Briefe gesamt</div></div>
|
||||
<div><div class="v out">612</div><div class="k out">ausgehend</div></div>
|
||||
<div><div class="v in">239</div><div class="k in">eingehend</div></div>
|
||||
<div><div class="v">42</div><div class="k">Jahre</div></div>
|
||||
</div>
|
||||
|
||||
<div class="dsec">
|
||||
<h3>Aktivität über die Jahre <span class="note">Spitzenjahr <b style="color:var(--brand-navy)">1922 · 78 Briefe</b></span></h3>
|
||||
<div class="hist">
|
||||
<div class="bar" style="height:12%" title="1898 · 8"></div>
|
||||
<div class="bar" style="height:18%" title="1899 · 12"></div>
|
||||
<div class="bar" style="height:26%" title="1900 · 18"></div>
|
||||
<div class="bar" style="height:38%" title="1901 · 26"></div>
|
||||
<div class="bar" style="height:44%" title="1902 · 30"></div>
|
||||
<div class="bar" style="height:52%" title="1903 · 36"></div>
|
||||
<div class="bar" style="height:60%" title="1904 · 42"></div>
|
||||
<div class="bar" style="height:68%" title="1905 · 48"></div>
|
||||
<div class="bar" style="height:80%" title="1920 · 62"></div>
|
||||
<div class="bar" style="height:88%" title="1921 · 68"></div>
|
||||
<div class="bar peak" style="height:100%" title="1922 · 78"></div>
|
||||
<div class="bar" style="height:72%" title="1923 · 54"></div>
|
||||
<div class="bar" style="height:58%" title="1924 · 42"></div>
|
||||
<div class="bar" style="height:48%" title="1925 · 34"></div>
|
||||
<div class="bar" style="height:38%" title="1926 · 27"></div>
|
||||
<div class="bar" style="height:28%" title="1927 · 20"></div>
|
||||
<div class="bar" style="height:22%" title="1928 · 16"></div>
|
||||
<div class="bar" style="height:18%" title="1929 · 13"></div>
|
||||
<div class="bar" style="height:14%" title="1930 · 10"></div>
|
||||
<div class="bar" style="height:10%" title="1932 · 7"></div>
|
||||
<div class="bar" style="height:6%" title="1935 · 4"></div>
|
||||
<div class="bar" style="height:4%" title="1938 · 3"></div>
|
||||
<div class="bar" style="height:2%" title="1940 · 1"></div>
|
||||
</div>
|
||||
<div class="hist-labels"><span>1898</span><span>1922 ▲</span><span>1940</span></div>
|
||||
</div>
|
||||
|
||||
<div class="dsec">
|
||||
<h3>Richtungsverteilung</h3>
|
||||
<div class="dsplit">
|
||||
<span class="out">→ 612 ausgehend · 72%</span>
|
||||
<span class="in">← 239 eingehend · 28%</span>
|
||||
</div>
|
||||
<div class="dbar">
|
||||
<span class="out" style="width:72%"></span>
|
||||
<span class="in" style="width:28%"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="twocol">
|
||||
<div class="dsec" style="border-top:1px solid var(--line-2)">
|
||||
<h3>Häufigste Korrespondenten <span class="note">Top 6 von 87</span></h3>
|
||||
<div class="toplist">
|
||||
<div class="ti"><span class="dir both">⇄</span><span class="name">Walter Dieckmann</span><span class="bar-wrap"><span class="bar" style="width:100%"></span></span><span class="val">184</span></div>
|
||||
<div class="ti"><span class="dir both">⇄</span><span class="name">Herbert Cram</span><span class="bar-wrap"><span class="bar" style="width:78%"></span></span><span class="val">143</span></div>
|
||||
<div class="ti"><span class="dir both">⇄</span><span class="name">Ella Dieckmann</span><span class="bar-wrap"><span class="bar" style="width:48%"></span></span><span class="val">88</span></div>
|
||||
<div class="ti"><span class="dir both">⇄</span><span class="name">Eugenie de Gruyter</span><span class="bar-wrap"><span class="bar" style="width:42%"></span></span><span class="val">77</span></div>
|
||||
<div class="ti"><span class="dir both">⇄</span><span class="name">Gertrud von Rofden</span><span class="bar-wrap"><span class="bar" style="width:32%"></span></span><span class="val">58</span></div>
|
||||
<div class="ti"><span class="dir both">⇄</span><span class="name">Käthe Dieckmann</span><span class="bar-wrap"><span class="bar" style="width:26%"></span></span><span class="val">47</span></div>
|
||||
</div>
|
||||
<div style="margin-top:10px"><a style="font-size:11px;color:var(--primary);font-weight:700;text-decoration:none;border-bottom:1px dashed var(--primary)">Alle 87 Korrespondenten →</a></div>
|
||||
</div>
|
||||
|
||||
<div class="dsec" style="border-top:1px solid var(--line-2)">
|
||||
<h3>Häufigste Orte <span class="note">Top 5 von 42</span></h3>
|
||||
<div class="toplist">
|
||||
<div class="ti"><span class="name">📍 Berlin</span><span class="bar-wrap"><span class="bar" style="width:100%"></span></span><span class="val">412</span></div>
|
||||
<div class="ti"><span class="name">📍 B.Lichterfelde</span><span class="bar-wrap"><span class="bar" style="width:44%"></span></span><span class="val">180</span></div>
|
||||
<div class="ti"><span class="name">📍 Bad Kissingen</span><span class="bar-wrap"><span class="bar" style="width:14%"></span></span><span class="val">58</span></div>
|
||||
<div class="ti"><span class="name">📍 Cöln</span><span class="bar-wrap"><span class="bar" style="width:9%"></span></span><span class="val">37</span></div>
|
||||
<div class="ti"><span class="name">📍 Belgard</span><span class="bar-wrap"><span class="bar" style="width:6%"></span></span><span class="val">26</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dsec">
|
||||
<h3>Beliebte Schlagwörter <span class="note">Klick filtert den Briefwechsel</span></h3>
|
||||
<div class="cloud">
|
||||
<span class="tag s-xl">Verlag</span>
|
||||
<span class="tag s-xl">Familie</span>
|
||||
<span class="tag s-l">Geburtstag</span>
|
||||
<span class="tag s-l">Weihnachten</span>
|
||||
<span class="tag s-m">Kuraufenthalt</span>
|
||||
<span class="tag s-m">Reise</span>
|
||||
<span class="tag s-m">Geschäft</span>
|
||||
<span class="tag s-s">Krieg</span>
|
||||
<span class="tag s-s muted">Krankheit</span>
|
||||
<span class="tag s-s muted">Schule</span>
|
||||
<span class="tag s-s muted">Hochzeit</span>
|
||||
<span class="tag s-s muted">Tod</span>
|
||||
<span class="tag s-s muted">Namenstag</span>
|
||||
<span class="tag s-s muted">Neujahr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing doc lists stay below the dashboard -->
|
||||
<div class="doclist-card">
|
||||
<div class="dh">Geschriebene Briefe <span class="cnt">612 Briefe · <a style="color:var(--primary);text-decoration:none;border-bottom:1px dashed">Alle anzeigen →</a></span></div>
|
||||
<div class="di"><span class="t">W-0397 – 2. September 1923 – B.Lichterfelde</span><span class="m">an Herbert Cram</span></div>
|
||||
<div class="di"><span class="t">W-0521 – 24. Dezember 1922 – Berlin</span><span class="m">an Walter Dieckmann</span></div>
|
||||
<div class="di"><span class="t">W-0392 – 23. November 1921 – Bad Kissingen</span><span class="m">an Herbert Cram</span></div>
|
||||
</div>
|
||||
|
||||
<div class="doclist-card">
|
||||
<div class="dh">Empfangene Briefe <span class="cnt">239 Briefe · <a style="color:var(--primary);text-decoration:none;border-bottom:1px dashed">Alle anzeigen →</a></span></div>
|
||||
<div class="di"><span class="t">Demo leserlicher Brief</span><span class="m">von Gertrud von Rofden</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:36px;padding:16px 20px;background:#fff;border-left:4px solid var(--brand-navy);font-size:13px;color:#333;line-height:1.65">
|
||||
<b style="color:var(--brand-navy)">How this ties back to /briefwechsel:</b>
|
||||
<ul style="margin:8px 0 0 18px;padding:0">
|
||||
<li>Every correspondent row → <code>/briefwechsel?senderId=<person>&receiverId=<other></code> (bilateral view)</li>
|
||||
<li>Every location → <code>/briefwechsel?senderId=<person>&location=<x></code></li>
|
||||
<li>Every tag → <code>/briefwechsel?senderId=<person>&tag=<x></code></li>
|
||||
<li>Every histogram year → <code>/briefwechsel?…&from=YYYY-01-01&to=YYYY-12-31</code></li>
|
||||
</ul>
|
||||
The dashboard is the discovery surface; /briefwechsel is the reading surface.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
102
docs/specs/briefwechsel-fill/_shared.css
Normal file
102
docs/specs/briefwechsel-fill/_shared.css
Normal file
@@ -0,0 +1,102 @@
|
||||
/* Shared CSS for briefwechsel-fill specs */
|
||||
:root {
|
||||
--brand-navy: #002850;
|
||||
--brand-mint: #A6DAD8;
|
||||
--brand-sand: #E4E2D7;
|
||||
--bg: #ECEAE4;
|
||||
--surface: #ffffff;
|
||||
--line: #E4E2D7;
|
||||
--line-2: #EFEDE6;
|
||||
--muted: #F7F5F2;
|
||||
--ink: #1A1A1A;
|
||||
--ink-3: #666;
|
||||
--primary: #002850;
|
||||
--accent: #2F9E95;
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5}
|
||||
.font-serif{font-family:'Merriweather',Georgia,serif}
|
||||
|
||||
/* ── Spec meta header ─── */
|
||||
.spec-meta{background:var(--brand-navy);color:#fff;padding:18px 28px;border-bottom:3px solid var(--brand-mint)}
|
||||
.spec-meta-inner{max-width:1440px;margin:0 auto;display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap}
|
||||
.spec-meta h1{font-size:18px;font-weight:800;letter-spacing:.4px}
|
||||
.spec-meta h1 span{opacity:.5;font-weight:400}
|
||||
.spec-meta p{font-size:12px;color:rgba(255,255,255,.7);margin-top:4px;max-width:600px}
|
||||
.spec-meta-right{font-size:11px;display:flex;gap:20px;flex-wrap:wrap}
|
||||
.spec-meta-right div strong{display:block;font-size:9px;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.45);font-weight:800;margin-bottom:2px}
|
||||
.spec-nav{background:#0a1e36;padding:10px 28px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.spec-nav-inner{max-width:1440px;margin:0 auto;display:flex;gap:4px;font-size:11px;flex-wrap:wrap;align-items:center}
|
||||
.spec-nav a{color:rgba(255,255,255,.55);text-decoration:none;padding:4px 10px;border-radius:3px;font-weight:600}
|
||||
.spec-nav a:hover{color:#fff;background:rgba(255,255,255,.06)}
|
||||
.spec-nav a.on{color:var(--brand-navy);background:var(--brand-mint)}
|
||||
.spec-nav .lbl{color:rgba(255,255,255,.3);font-size:9px;text-transform:uppercase;letter-spacing:1px;margin-right:8px}
|
||||
|
||||
/* ── Real page chrome ─── */
|
||||
.page-wrap{background:var(--bg);min-height:calc(100vh - 120px)}
|
||||
.hdr{background:var(--brand-navy);color:#fff;padding:0 32px;height:64px;display:flex;align-items:center;gap:40px}
|
||||
.hdr-logo{font-size:16px;font-weight:900;letter-spacing:1.5px}
|
||||
.hdr-nav{display:flex;gap:28px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.8px}
|
||||
.hdr-nav a{color:rgba(255,255,255,.7);text-decoration:none;padding:4px 0;border-bottom:2px solid transparent}
|
||||
.hdr-nav a.on{color:#fff;border-color:var(--brand-mint)}
|
||||
.hdr-right{margin-left:auto;display:flex;gap:14px;align-items:center;font-size:11px;color:rgba(255,255,255,.65)}
|
||||
.hdr-upload{border:1px solid rgba(255,255,255,.3);border-radius:4px;padding:6px 12px;font-weight:700;letter-spacing:.5px}
|
||||
.hdr-avatar{width:32px;height:32px;border-radius:50%;background:var(--brand-mint);color:var(--brand-navy);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:12px}
|
||||
|
||||
.page{max-width:1440px;margin:0 auto;padding:28px 32px}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--line);border-radius:2px;box-shadow:0 1px 2px rgba(0,0,0,.02);padding:20px 24px;margin-bottom:24px}
|
||||
.filter-row{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:14px}
|
||||
.fl{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:6px}
|
||||
.fi{height:40px;border:1px solid #D1D5DB;border-radius:2px;background:#fff;padding:0 12px;font-size:13px;display:flex;align-items:center;font-family:'Merriweather',serif}
|
||||
.fi.empty{color:#bbb;font-style:italic}
|
||||
.filter-actions{display:flex;gap:8px;align-items:center}
|
||||
.btn{height:34px;border:1px solid #D1D5DB;background:#F7F5F2;border-radius:2px;padding:0 12px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#444;display:inline-flex;align-items:center;gap:6px}
|
||||
.count{margin-left:auto;font-size:13px;color:#555}
|
||||
.count b{color:var(--brand-navy)}
|
||||
|
||||
.hintbar{margin-top:14px;background:#e9f5f4;border:1px solid #c8e6e4;border-radius:2px;padding:10px 14px;font-size:12px;color:#1a3e3c;display:flex;align-items:center;gap:8px}
|
||||
.hintbar b{color:var(--brand-navy)}
|
||||
|
||||
/* ── Year divider ─── */
|
||||
.year-divider{display:flex;align-items:baseline;gap:12px;background:var(--muted);border-top:2px solid var(--line);border-bottom:1px solid var(--line);padding:8px 14px}
|
||||
.year-divider .y{font-size:22px;font-weight:900;color:var(--brand-navy);letter-spacing:-.5px}
|
||||
.year-divider .n{font-size:12px;font-weight:700;color:#888}
|
||||
|
||||
/* ── Status dots ─── */
|
||||
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.dot.uploaded{background:var(--brand-mint)}
|
||||
.dot.transcribed{background:var(--brand-mint)}
|
||||
.dot.reviewed{background:rgba(0,40,80,.55)}
|
||||
.dot.archived{background:var(--brand-navy)}
|
||||
|
||||
/* ── Direction indicator ─── */
|
||||
.dir{width:16px;height:16px;flex-shrink:0;opacity:.6}
|
||||
.dir-out{color:var(--primary)}
|
||||
.dir-in{color:var(--accent)}
|
||||
|
||||
/* ── Concept intro banner ─── */
|
||||
.concept-intro{background:#fff;border-left:4px solid var(--brand-mint);padding:14px 20px;margin-bottom:24px;font-size:13px;color:#333;border-radius:2px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||
.concept-intro h2{font-size:14px;font-weight:800;color:var(--brand-navy);margin-bottom:4px}
|
||||
.concept-intro .gain,.concept-intro .cost{display:inline-block;font-size:11px;margin-right:14px;margin-top:6px}
|
||||
.concept-intro .gain{color:#166534}
|
||||
.concept-intro .cost{color:#92400E}
|
||||
|
||||
/* ── Mock letter thumbnail ─── */
|
||||
.thumb{background:linear-gradient(180deg,#fdfcf7 0%,#f6f3ea 100%);border:1px solid #d9d4c6;box-shadow:inset 0 0 0 1px #fff,0 1px 2px rgba(0,0,0,.05);position:relative;overflow:hidden;flex-shrink:0;border-radius:1px}
|
||||
.thumb::before{content:'';position:absolute;top:0;left:0;right:0;height:28%;background:linear-gradient(135deg,rgba(166,218,216,.25) 0%,transparent 60%)}
|
||||
.thumb-lines{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;gap:2px;padding:20% 12% 20% 12%}
|
||||
.thumb-lines i{display:block;height:1px;background:rgba(0,40,80,.2)}
|
||||
.thumb-lines i:nth-child(odd){width:95%}
|
||||
.thumb-lines i:nth-child(3n){width:70%}
|
||||
.thumb-lines i:nth-child(5n){width:80%}
|
||||
.thumb.handwritten .thumb-lines{padding:15% 10%;gap:4px}
|
||||
.thumb.handwritten .thumb-lines i{height:1.2px;background:rgba(0,40,80,.35);transform:rotate(-.6deg)}
|
||||
|
||||
/* ── Tag chip ─── */
|
||||
.tag{display:inline-flex;align-items:center;font-size:10px;font-weight:700;background:var(--brand-mint);color:var(--brand-navy);padding:2px 7px;border-radius:10px;letter-spacing:.3px}
|
||||
.tag.muted{background:#EEE8DC;color:#666}
|
||||
|
||||
/* ── Links & generic ─── */
|
||||
a{color:inherit}
|
||||
.hide{display:none}
|
||||
290
docs/specs/briefwechsel-fill/index.html
Normal file
290
docs/specs/briefwechsel-fill/index.html
Normal file
@@ -0,0 +1,290 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Briefwechsel — Fill the Empty Rows · Overview</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="_shared.css">
|
||||
<style>
|
||||
.ov{max-width:1440px;margin:0 auto;padding:36px 32px 80px}
|
||||
.lead{display:grid;grid-template-columns:1fr 320px;gap:40px;margin-bottom:36px;align-items:start}
|
||||
@media (max-width: 900px){ .lead{grid-template-columns:1fr} }
|
||||
.lead h2{font-family:'Merriweather',serif;font-size:24px;font-weight:700;color:var(--brand-navy);margin-bottom:10px;line-height:1.3}
|
||||
.lead p{color:#444;font-size:14px;line-height:1.65}
|
||||
.lead .kit{background:#fff;border:1px solid var(--line);border-radius:2px;padding:16px 18px}
|
||||
.lead .kit h3{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:10px}
|
||||
.lead .kit li{font-size:12px;padding:5px 0;border-top:1px dashed var(--line);display:flex;justify-content:space-between}
|
||||
.lead .kit li:first-child{border-top:0}
|
||||
.lead .kit li b{color:var(--brand-navy);font-weight:700}
|
||||
|
||||
.section-h{font-family:'Merriweather',serif;font-size:16px;font-weight:700;color:var(--brand-navy);margin:28px 0 12px;padding-top:20px;border-top:1px dashed var(--line)}
|
||||
.section-h .sub{font-family:'Montserrat',sans-serif;font-size:12px;color:#888;font-weight:400;margin-left:10px}
|
||||
|
||||
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||||
@media (max-width: 1200px){ .grid4{grid-template-columns:repeat(2,1fr)} }
|
||||
.grid1{display:grid;grid-template-columns:1fr;gap:16px}
|
||||
.spec-card{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden;text-decoration:none;color:inherit;display:flex;flex-direction:column;transition:transform .12s,box-shadow .12s}
|
||||
.spec-card:hover{transform:translateY(-3px);box-shadow:0 8px 26px rgba(0,0,0,.1)}
|
||||
.spec-card.wide{flex-direction:row}
|
||||
.spec-card.wide .mini{width:45%;aspect-ratio:auto;min-height:280px;border-right:1px solid var(--line);border-bottom:0}
|
||||
.spec-card.wide .sc-body{flex:1}
|
||||
.spec-card .sc-num{background:var(--brand-navy);color:#fff;padding:3px 8px;font-size:10px;font-weight:800;letter-spacing:.8px;display:inline-block;border-radius:0 0 3px 0;width:max-content}
|
||||
.spec-card .sc-body{padding:14px 16px;flex:1;display:flex;flex-direction:column;gap:8px}
|
||||
.spec-card h3{font-family:'Merriweather',serif;font-size:16px;font-weight:700;color:var(--brand-navy);line-height:1.35}
|
||||
.spec-card p{font-size:12px;color:#555;line-height:1.55}
|
||||
.spec-card .tags{display:flex;gap:4px;flex-wrap:wrap;margin-top:auto;padding-top:8px}
|
||||
.spec-card .tag{font-size:9.5px;letter-spacing:.3px}
|
||||
|
||||
/* Mini previews */
|
||||
.mini{aspect-ratio:16/10;border-bottom:1px solid var(--line);background:#fafaf5;display:flex;padding:8px;gap:4px}
|
||||
.mini.col{flex-direction:column}
|
||||
.mini .minirow{background:#fff;border:1px solid var(--line-2);border-left:2px solid var(--primary);padding:4px 6px;display:flex;flex-direction:column;gap:2px;flex:1;font-size:7px;color:#888}
|
||||
.mini .minirow b{color:var(--brand-navy);font-size:8px;font-weight:700}
|
||||
.mini .minirow.in{border-left-color:var(--accent)}
|
||||
|
||||
.mini-1{display:flex;flex-direction:column;gap:3px;padding:8px}
|
||||
.mini-1 .r{background:#fff;border:1px solid var(--line-2);border-left:2px solid var(--primary);padding:4px 6px;display:grid;grid-template-columns:1fr 48px;gap:4px;flex:1;font-size:7px;color:#888;align-items:center}
|
||||
.mini-1 .r b{color:var(--brand-navy);font-size:8px;font-weight:700;font-family:'Merriweather',serif}
|
||||
.mini-1 .r .ar{font-size:6.5px;color:#888;background:#F4F1EA;padding:2px 4px;text-align:center}
|
||||
.mini-1 .r .tg{display:flex;gap:2px;margin-top:1px}
|
||||
.mini-1 .r .tg span{background:var(--brand-mint);padding:0 3px;font-size:6px;border-radius:3px;color:var(--brand-navy)}
|
||||
|
||||
.mini-2{display:flex;flex-direction:column;gap:3px;padding:8px}
|
||||
.mini-2 .r2{background:#fff;border:1px solid var(--line-2);border-left:2px solid var(--primary);padding:4px;display:flex;gap:4px;align-items:center;flex:1}
|
||||
.mini-2 .th2{width:22px;height:28px;background:linear-gradient(#fdfcf7,#f1eadb);border:1px solid #d9d4c6;flex-shrink:0;position:relative}
|
||||
.mini-2 .th2::before{content:'';position:absolute;inset:2px 3px;border-top:1px solid rgba(0,40,80,.25);border-bottom:1px solid rgba(0,40,80,.25);opacity:.6}
|
||||
.mini-2 .content{flex:1;display:flex;flex-direction:column;gap:2px;font-size:7px;color:#888}
|
||||
.mini-2 .content b{color:var(--brand-navy);font-size:8px;font-family:'Merriweather',serif}
|
||||
.mini-2 .content i{color:#555;font-style:italic}
|
||||
.mini-2 .content .dt{font-size:7px;color:#888;align-self:flex-end;font-family:'Merriweather',serif}
|
||||
|
||||
.mini-3{display:grid;grid-template-columns:1fr 1.1fr;gap:4px;padding:8px}
|
||||
.mini-3 .left-list{display:flex;flex-direction:column;gap:2px}
|
||||
.mini-3 .left-list .r{background:#fff;border:1px solid var(--line-2);border-left:2px solid var(--primary);padding:2px 4px;font-size:6.5px;color:#888}
|
||||
.mini-3 .left-list .r.sel{background:#e7f4f3;border-left-color:var(--accent)}
|
||||
.mini-3 .left-list .r b{color:var(--brand-navy);font-size:7px}
|
||||
.mini-3 .preview-mini{background:#fff;border:1px solid var(--line-2);padding:5px;display:flex;gap:4px}
|
||||
.mini-3 .pv-th{width:28px;height:38px;background:linear-gradient(#fdfcf7,#f1eadb);border:1px solid #d9d4c6;flex-shrink:0}
|
||||
.mini-3 .pv-meta{font-size:6px;color:#888;line-height:1.4}
|
||||
.mini-3 .pv-meta b{color:var(--brand-navy);font-size:7px;display:block;font-family:'Merriweather',serif}
|
||||
|
||||
.mini-4{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;padding:8px}
|
||||
.mini-4 .card-s{background:#fff;border:1px solid var(--line-2);overflow:hidden;display:flex;flex-direction:column}
|
||||
.mini-4 .card-s .th{aspect-ratio:3/4;background:linear-gradient(#fdfcf7,#f1eadb);border-bottom:1px solid var(--line-2)}
|
||||
.mini-4 .card-s .t{font-size:5.5px;color:var(--brand-navy);font-weight:700;padding:3px 4px;font-family:'Merriweather',serif}
|
||||
|
||||
.mini-5{display:grid;grid-template-columns:32% 1fr;gap:6px;padding:10px;align-items:start;min-height:260px}
|
||||
.mini-5 .person-left{background:#fff;border:1px solid var(--line-2);padding:8px;display:flex;flex-direction:column;align-items:center;gap:4px}
|
||||
.mini-5 .person-left .av{width:28px;height:28px;border-radius:50%;background:var(--brand-mint);color:var(--brand-navy);display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:800;font-family:'Merriweather',serif}
|
||||
.mini-5 .person-left .nm{font-family:'Merriweather',serif;font-size:7.5px;font-weight:700;color:var(--brand-navy);text-align:center}
|
||||
.mini-5 .person-left .dt{font-size:6px;color:#888}
|
||||
.mini-5 .dash-mini{background:#fff;border:1px solid var(--line-2);display:flex;flex-direction:column}
|
||||
.mini-5 .dh{background:var(--brand-navy);color:#fff;font-family:'Merriweather',serif;font-size:6.5px;padding:4px 6px;display:flex;justify-content:space-between;align-items:center}
|
||||
.mini-5 .dh span{background:var(--brand-mint);color:var(--brand-navy);font-size:5.5px;padding:1px 4px;border-radius:2px;font-weight:800}
|
||||
.mini-5 .stats-m{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line-2);border-bottom:1px solid var(--line-2)}
|
||||
.mini-5 .stats-m div{background:#fafaf5;padding:3px;text-align:center}
|
||||
.mini-5 .stats-m .v{font-family:'Merriweather',serif;font-size:8px;font-weight:900;color:var(--brand-navy)}
|
||||
.mini-5 .stats-m .k{font-size:5px;color:#888;font-weight:700}
|
||||
.mini-5 .hist-m{display:flex;align-items:flex-end;gap:1px;height:22px;padding:4px 5px 0}
|
||||
.mini-5 .hist-m i{flex:1;background:var(--brand-mint);opacity:.6}
|
||||
.mini-5 .hist-m i.p{background:var(--brand-navy);opacity:.9}
|
||||
.mini-5 .bars-m{display:flex;flex-direction:column;gap:1.5px;padding:4px 5px}
|
||||
.mini-5 .bars-m span{display:flex;align-items:center;gap:2px;font-size:5.5px;color:#555}
|
||||
.mini-5 .bars-m span .bar{flex:1;height:2px;background:var(--brand-navy);border-radius:1px}
|
||||
.mini-5 .cloud-m{padding:4px 5px;display:flex;flex-wrap:wrap;gap:2px}
|
||||
.mini-5 .cloud-m span{background:var(--brand-mint);padding:0 3px;border-radius:3px;font-size:5.5px;color:var(--brand-navy);font-weight:700}
|
||||
|
||||
/* Comparison table */
|
||||
.cmp{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden;margin-top:36px}
|
||||
.cmp table{width:100%;border-collapse:collapse}
|
||||
.cmp th,.cmp td{padding:10px 14px;text-align:left;font-size:12px;border-bottom:1px solid var(--line-2);vertical-align:top}
|
||||
.cmp th{background:#fafaf5;font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:#888;font-weight:800}
|
||||
.cmp td:first-child{font-weight:700;color:var(--brand-navy);font-family:'Merriweather',serif}
|
||||
.cmp .yes{color:#166534}
|
||||
.cmp .no{color:#B91C1C}
|
||||
.cmp .mid{color:#92400E}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="spec-meta">
|
||||
<div class="spec-meta-inner">
|
||||
<div>
|
||||
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
|
||||
<p>Five approaches. The first four change the <b>/briefwechsel</b> row itself; the fifth puts insights where they belong — on the person detail page.</p>
|
||||
</div>
|
||||
<div class="spec-meta-right">
|
||||
<div><strong>Page</strong>/briefwechsel · /persons/[id]</div>
|
||||
<div><strong>Brief</strong>Rows feel sparse — ~60% of row width is empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="spec-nav">
|
||||
<div class="spec-nav-inner">
|
||||
<span class="lbl">Specs</span>
|
||||
<a class="on" href="index.html">Overview</a>
|
||||
<a href="01-rich-rows.html">1 · Rich Rows</a>
|
||||
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
|
||||
<a href="03-master-detail.html">3 · Master-Detail Split</a>
|
||||
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
|
||||
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="ov">
|
||||
|
||||
<div class="lead">
|
||||
<div>
|
||||
<h2>The situation</h2>
|
||||
<p>On <code>/briefwechsel?senderId=…</code> (e.g. 851 letters for Walter de Gruyter), each row shows only title, date, location and counterpart — leaving the right half empty. The question is: what belongs there?</p>
|
||||
<p style="margin-top:10px">The five specs answer differently. Specs 1–4 rework the row itself. <b>Spec 5</b> argues the archive-level view (top correspondents, activity, tag cloud) belongs on <code>/persons/[id]</code>, not here — and mocks the dashboard that lives there instead.</p>
|
||||
</div>
|
||||
<div class="kit">
|
||||
<h3>Data we can use today</h3>
|
||||
<ul style="list-style:none">
|
||||
<li>Title / Filename <b>✓</b></li>
|
||||
<li>Document date <b>✓</b></li>
|
||||
<li>Location <b>✓</b></li>
|
||||
<li>Sender / receivers <b>✓</b></li>
|
||||
<li>Summary text <b>✓</b></li>
|
||||
<li>Tags <b>✓</b></li>
|
||||
<li>Archive box & folder <b>✓</b></li>
|
||||
<li>PDF thumbnail <b>open issue</b></li>
|
||||
</ul>
|
||||
<p style="font-size:11px;color:#888;margin-top:10px;font-style:italic">Removed from earlier drafts: status lifecycle (will be dropped from the product) and script type (only set after OCR, unreliable).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-h">Concepts that rework /briefwechsel <span class="sub">Specs 1–4</span></div>
|
||||
|
||||
<div class="grid4">
|
||||
<a class="spec-card" href="01-rich-rows.html">
|
||||
<span class="sc-num">01</span>
|
||||
<div class="mini mini-1">
|
||||
<div class="r"><span><b>Demo leserlicher Brief</b><br>31. Mai 1940 · Belgard<br>„letzte Lebenstage…"<div class="tg"><span>Dörpfeld</span></div></span><div class="ar">VII · 5</div></div>
|
||||
<div class="r"><span><b>W-0397 – 2. Sep 1923</b><br>B.Lichterfelde · H. Cram<br>„von Elsbeth…"<div class="tg"><span>Verlag</span></div></span><div class="ar">VI · 7</div></div>
|
||||
<div class="r"><span><b>W-0521 – 24. Dez 1922</b><br>Berlin · W. Dieckmann<br>„Weihnachtsbrief…"<div class="tg"><span>Weihn.</span></div></span><div class="ar">V · 3</div></div>
|
||||
</div>
|
||||
<div class="sc-body">
|
||||
<h3>Rich Rows</h3>
|
||||
<p>Pack summary, tags and archive box into each row. No images, no structural change. Fastest to ship.</p>
|
||||
<div class="tags"><span class="tag">Scanning</span><span class="tag muted">Small effort</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="spec-card" href="02-thumbnail-rows.html">
|
||||
<span class="sc-num">02</span>
|
||||
<div class="mini mini-2">
|
||||
<div class="r2"><div class="th2"></div><div class="content"><b>Demo leserlicher Brief</b><i>„letzte Lebenstage von W. Dörpfeld…"</i><span>← Gertrud · Belgard</span><span class="dt">31. Mai 1940</span></div></div>
|
||||
<div class="r2"><div class="th2"></div><div class="content"><b>W-0397 – 2. Sep 1923</b><i>„von Elsbeth geschriebener Kommentar…"</i><span>→ H. Cram · B.Lichterfelde</span><span class="dt">2. Sep 1923</span></div></div>
|
||||
<div class="r2"><div class="th2"></div><div class="content"><b>W-0521 – 24. Dez 1922</b><i>„Weihnachtsbrief, Bitte um Bild…"</i><span>→ W. Dieckmann · Berlin</span><span class="dt">24. Dez 1922</span></div></div>
|
||||
</div>
|
||||
<div class="sc-body">
|
||||
<h3>Thumbnail Rows</h3>
|
||||
<p>PDF preview on the left anchors each row. Summary (when filled) becomes the readable context line.</p>
|
||||
<div class="tags"><span class="tag">Recognition</span><span class="tag muted">Needs thumbnails</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="spec-card" href="03-master-detail.html">
|
||||
<span class="sc-num">03</span>
|
||||
<div class="mini mini-3">
|
||||
<div class="left-list">
|
||||
<div class="r"><b>Demo Brief</b></div>
|
||||
<div class="r sel"><b>W-0397</b></div>
|
||||
<div class="r"><b>W-0396</b></div>
|
||||
<div class="r"><b>W-0524</b></div>
|
||||
<div class="r"><b>W-0523</b></div>
|
||||
</div>
|
||||
<div class="preview-mini">
|
||||
<div class="pv-th"></div>
|
||||
<div class="pv-meta"><b>W-0397</b>2. Sep 1923<br>B.Lichterfelde<br>→ H. Cram<br><br>„von Elsbeth…"</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc-body">
|
||||
<h3>Master-Detail Split</h3>
|
||||
<p>Compact list left, sticky preview right. Click a row → thumbnail, metadata, summary, excerpt. Browse without losing context.</p>
|
||||
<div class="tags"><span class="tag">Reading flow</span><span class="tag muted">Mobile pattern needed</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="spec-card" href="04-gallery-cards.html">
|
||||
<span class="sc-num">04</span>
|
||||
<div class="mini mini-4">
|
||||
<div class="card-s"><div class="th"></div><div class="t">Demo Brief</div></div>
|
||||
<div class="card-s"><div class="th"></div><div class="t">W-0397 · 2 Sep</div></div>
|
||||
<div class="card-s"><div class="th"></div><div class="t">W-0396 · 2 Sep</div></div>
|
||||
<div class="card-s"><div class="th"></div><div class="t">W-0524 · 31 Jul</div></div>
|
||||
<div class="card-s"><div class="th"></div><div class="t">W-0523 · 12 Mai</div></div>
|
||||
<div class="card-s"><div class="th"></div><div class="t">W-0522 · 7 Mär</div></div>
|
||||
</div>
|
||||
<div class="sc-body">
|
||||
<h3>Gallery Cards</h3>
|
||||
<p>Abandon the list for a 4-column grid. Thumbnail-first, family-album feel. Biggest visual change.</p>
|
||||
<div class="tags"><span class="tag">Browsing</span><span class="tag muted">Poor date-scanning</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section-h">The archive-level view moves to /persons/[id] <span class="sub">Spec 5 · separate page</span></div>
|
||||
|
||||
<div class="grid1">
|
||||
<a class="spec-card wide" href="05-person-dashboard.html">
|
||||
<span class="sc-num" style="position:absolute">05</span>
|
||||
<div class="mini mini-5">
|
||||
<div class="person-left">
|
||||
<div class="av">WG</div>
|
||||
<div class="nm">Walter de Gruyter</div>
|
||||
<div class="dt">1862–1923</div>
|
||||
</div>
|
||||
<div class="dash-mini">
|
||||
<div class="dh">Korrespondenz-Überblick <span>↗ Briefwechsel</span></div>
|
||||
<div class="stats-m">
|
||||
<div><div class="v">851</div><div class="k">gesamt</div></div>
|
||||
<div><div class="v" style="color:var(--primary)">612</div><div class="k">→</div></div>
|
||||
<div><div class="v" style="color:var(--accent)">239</div><div class="k">←</div></div>
|
||||
<div><div class="v">42J</div><div class="k">Jahre</div></div>
|
||||
</div>
|
||||
<div class="hist-m"><i style="height:15%"></i><i style="height:30%"></i><i style="height:45%"></i><i style="height:60%"></i><i style="height:80%"></i><i class="p" style="height:100%"></i><i style="height:75%"></i><i style="height:55%"></i><i style="height:40%"></i><i style="height:25%"></i><i style="height:15%"></i><i style="height:8%"></i></div>
|
||||
<div class="bars-m"><span>W. Dieckmann<span class="bar" style="flex:1"></span>184</span><span>H. Cram<span class="bar" style="flex:.78"></span>143</span><span>E. Dieckmann<span class="bar" style="flex:.48"></span>88</span></div>
|
||||
<div class="cloud-m"><span>Verlag</span><span>Familie</span><span>Weihn.</span><span>Kur</span><span>Reise</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc-body">
|
||||
<h3>Person Dashboard (/persons/[id])</h3>
|
||||
<p>The /briefwechsel list stays calm and reading-focused; the archive-level view — activity over years, top correspondents, top locations, tag cloud — lives on the person detail page, where it's useful <i>every time</i> you open a person, not only during letter review.</p>
|
||||
<p>Every correspondent, location, tag and year on the dashboard links into <code>/briefwechsel</code> with pre-filled filters, so the dashboard is the discovery surface and /briefwechsel is the reading surface.</p>
|
||||
<div class="tags"><span class="tag">Discovery</span><span class="tag">Works for bilateral too (always scoped to the person)</span><span class="tag muted">Needs aggregation endpoints</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="cmp">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Concept</th><th>Best for user who wants to…</th><th>Visual change</th><th>New backend</th><th>Effort</th><th>Mobile</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1 · Rich Rows</td><td>Scan quickly, see summary + tags on every letter</td><td>Row height 2× current</td><td>None</td><td class="yes">Small</td><td class="yes">Right column collapses</td></tr>
|
||||
<tr><td>2 · Thumbnail Rows</td><td>Recognise letters visually, pick up where they left off</td><td>Thumbnail on the left</td><td>PDF thumbnail service (open issue)</td><td class="mid">Medium</td><td class="yes">Fine</td></tr>
|
||||
<tr><td>3 · Master-Detail</td><td>Flip through letters in a reading session</td><td>Two-column split</td><td>None mandatory</td><td class="mid">Medium</td><td class="no">Drawer / sheet needed</td></tr>
|
||||
<tr><td>4 · Gallery Cards</td><td>Browse the collection as an album</td><td>Full structural change (list → grid)</td><td>PDF thumbnail service</td><td class="no">Large</td><td class="yes">Grid reflows 4 → 2</td></tr>
|
||||
<tr><td>5 · Person Dashboard</td><td>Understand a person's correspondence at a glance</td><td>New section on /persons/[id]</td><td>Aggregation endpoints (per-year, per-correspondent, per-location, per-tag)</td><td class="mid">Medium</td><td class="yes">Stacks naturally</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:28px;padding:16px 20px;background:#fff;border-left:4px solid var(--brand-navy);font-size:13px;color:#333;line-height:1.65">
|
||||
<b style="color:var(--brand-navy)">Proposed path forward:</b>
|
||||
<ol style="margin:8px 0 0 20px;padding:0">
|
||||
<li><b>Ship Spec 1 (Rich Rows)</b> now — it uses data we already have and tests whether "empty rows" is really the problem, or whether it's "not enough context to decide which letter to open".</li>
|
||||
<li><b>Build Spec 5 (Person Dashboard)</b> next — it's independent of /briefwechsel and turns the person page into a real archive overview.</li>
|
||||
<li><b>Upgrade to Spec 2 (Thumbnail Rows)</b> once the thumbnail service lands — it layers cleanly on top of Spec 1 without throwing work away.</li>
|
||||
</ol>
|
||||
<div style="margin-top:10px">Specs 3 and 4 remain on the table but are bigger re-architectures — revisit after watching how users behave with 1 + 5 + 2.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1073
docs/specs/briefwechsel-thumbnail-rows-spec.html
Normal file
1073
docs/specs/briefwechsel-thumbnail-rows-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
996
docs/specs/bulk-upload-concepts.html
Normal file
996
docs/specs/bulk-upload-concepts.html
Normal file
@@ -0,0 +1,996 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Bulk Upload — 3 Concept Designs · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;1,300&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
/* ── Reset ── */
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
|
||||
|
||||
/* ── Masthead ── */
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #002850;margin-bottom:60px}
|
||||
.mh .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
|
||||
.mh h1{font-size:28px;font-weight:900;color:#002850;letter-spacing:-.4px;margin-top:6px}
|
||||
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:10px}
|
||||
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:14px}
|
||||
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
||||
.tag{background:#002850;color:#A6DAD8;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||
.tag.amber{background:#7c4a00;color:#fde68a}
|
||||
.tag.green{background:#1e5e34;color:#d1fae5}
|
||||
.tag.gray{background:#4b5563;color:#e5e7eb}
|
||||
.tag.mint{background:#A6DAD8;color:#002850}
|
||||
|
||||
/* ── Goals card ── */
|
||||
.goals{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:22px 26px;margin:0 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.goals h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:14px}
|
||||
.goals ul{list-style:none;display:grid;grid-template-columns:1fr 1fr;gap:10px 28px}
|
||||
.goals li{font-size:12.5px;color:#333;padding-left:20px;position:relative;line-height:1.55}
|
||||
.goals li::before{content:"→";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
|
||||
/* ── Concept section ── */
|
||||
.concept{margin-bottom:88px;padding-bottom:88px;border-bottom:2px dashed #C8C4BE}
|
||||
.concept:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||
.concept-header{display:flex;align-items:flex-start;gap:24px;margin-bottom:36px}
|
||||
.concept-num{font-size:84px;font-weight:900;color:#E0DDD6;line-height:1;flex-shrink:0;width:96px}
|
||||
.concept-label{font-size:8.5px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#A6DAD8;margin-bottom:5px}
|
||||
.concept-title{font-family:'Merriweather',Georgia,serif;font-size:24px;font-weight:700;color:#002850;margin-bottom:10px}
|
||||
.concept-desc{font-size:13.5px;color:#555;max-width:740px;line-height:1.75}
|
||||
.concept-best{margin-top:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.best-label{background:#A6DAD8;color:#002850;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
|
||||
.best-text{font-size:12px;font-weight:600;color:#444}
|
||||
.concept-tradeoff{margin-top:8px;font-size:12px;color:#888;font-style:italic;max-width:680px;line-height:1.7}
|
||||
|
||||
/* ── Browser chrome ── */
|
||||
.screen{max-width:980px;margin:0 auto}
|
||||
.screen.narrow{max-width:400px}
|
||||
.chrome{background:#F5F4EE;border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
|
||||
.chrome-bar{height:22px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 9px;gap:5px;flex-shrink:0}
|
||||
.chrome-dot{width:7px;height:7px;border-radius:50%;background:#BDB8B1}
|
||||
.chrome-url{flex:1;height:10px;background:#CCC8C2;border-radius:5px;margin-left:8px}
|
||||
.viewport-hint{font-size:7.5px;font-weight:800;color:#A6DAD8;letter-spacing:1px;text-transform:uppercase;padding:4px 9px;background:#002850;border-radius:2px;margin-left:8px}
|
||||
|
||||
/* ── App nav ── */
|
||||
.app-nav{height:32px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:12px;flex-shrink:0}
|
||||
.app-logo{font-family:'Merriweather',Georgia,serif;font-size:8px;font-weight:700;color:#fff;border-bottom:2px solid #A6DAD8;padding-bottom:1px}
|
||||
.app-link{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.45);white-space:nowrap}
|
||||
.app-link.on{color:rgba(255,255,255,.9)}
|
||||
.app-nav-r{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||||
.app-avatar{width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.5)}
|
||||
|
||||
/* ── Common form element styles ── */
|
||||
.f-label{font-size:6.5px;font-weight:700;color:#666;letter-spacing:.2px;text-transform:uppercase}
|
||||
.f-req{color:#C0392B}
|
||||
.f-input{height:20px;border:1px solid #D4D0CA;border-radius:2px;background:#fff;font-size:7.5px;padding:0 7px;color:#333;display:flex;align-items:center}
|
||||
.f-input.focus{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.12)}
|
||||
.f-input.filled{color:#002850;font-weight:600;background:#FAFBFF}
|
||||
.f-input.suggested{border-color:#A6DAD8;background:#F0FAFA;color:#005858;font-weight:600}
|
||||
.f-input.empty{color:#BBB;font-style:italic}
|
||||
.f-input.tall{height:28px}
|
||||
|
||||
.f-tags{display:flex;gap:3px;flex-wrap:wrap;min-height:20px;border:1px solid #D4D0CA;border-radius:2px;padding:2px 4px;background:#fff;align-items:center}
|
||||
.f-chip{background:#002850;color:#A6DAD8;border-radius:2px;font-size:6px;font-weight:700;padding:1px 4px 1px 5px;display:flex;align-items:center;gap:2px}
|
||||
.f-chip-rm{color:rgba(166,218,216,.5);font-weight:400}
|
||||
|
||||
/* ── Action bar ── */
|
||||
.action-bar{height:46px;background:#F5F4EE;border-top:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px;flex-shrink:0}
|
||||
.btn-skip{font-size:7px;font-weight:700;color:#AAA;letter-spacing:.2px;cursor:pointer}
|
||||
.btn-spacer{flex:1}
|
||||
.btn-outline{height:24px;padding:0 12px;border:1px solid #C0BDB6;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#777;display:flex;align-items:center;cursor:pointer;background:#fff}
|
||||
.btn-primary{height:24px;padding:0 12px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;background:#002850;color:#fff;display:flex;align-items:center;cursor:pointer;gap:4px}
|
||||
.btn-primary.green{background:#1A7040}
|
||||
|
||||
/* ─────────────────────────────────────── */
|
||||
/* ── CONCEPT A — Stack (mobile-first) ── */
|
||||
/* ─────────────────────────────────────── */
|
||||
.ca-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:8px}
|
||||
.ca-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||
.ca-title{flex:1;text-align:center;font-family:'Merriweather',Georgia,serif;font-size:9px;color:#002850;font-weight:600}
|
||||
.ca-count{font-size:7px;font-weight:700;color:#002850;background:#A6DAD8;padding:2px 6px;border-radius:10px;letter-spacing:.3px}
|
||||
|
||||
.ca-body{background:#ECEAE4;padding:14px 12px;overflow-y:auto}
|
||||
|
||||
.ca-drop{background:#fff;border:2px dashed #A6DAD8;border-radius:4px;padding:14px;text-align:center;margin-bottom:14px}
|
||||
.ca-drop-icon{font-size:18px;color:#A6DAD8;margin-bottom:4px}
|
||||
.ca-drop-title{font-size:8.5px;font-weight:700;color:#002850;margin-bottom:2px}
|
||||
.ca-drop-sub{font-size:6.5px;color:#999}
|
||||
|
||||
.ca-shared-card{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.03)}
|
||||
.ca-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:11px}
|
||||
.ca-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.ca-shared-title{font-family:'Merriweather',Georgia,serif;font-size:9.5px;color:#002850;font-weight:700}
|
||||
.ca-shared-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 10px}
|
||||
.ca-shared-grid .full{grid-column:1/-1}
|
||||
.ca-shared-field{display:flex;flex-direction:column;gap:3px}
|
||||
|
||||
.ca-files-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 2px}
|
||||
.ca-files-title{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6}
|
||||
.ca-files-add{font-size:7px;font-weight:700;color:#002850;display:flex;align-items:center;gap:3px}
|
||||
|
||||
.ca-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:9px 10px;margin-bottom:7px;display:flex;align-items:center;gap:10px}
|
||||
.ca-file.active{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.08)}
|
||||
.ca-thumb{width:28px;height:36px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;flex-shrink:0;display:flex;flex-direction:column;padding:3px;gap:1px}
|
||||
.ca-thumb .tl{height:2px;background:#C4BDB0;opacity:.6;border-radius:1px}
|
||||
.ca-thumb .tl.s{width:60%;opacity:.35}
|
||||
.ca-thumb .tl.m{width:82%}
|
||||
.ca-file-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||
.ca-file-title{font-size:8px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ca-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
|
||||
.ca-file-meta{font-size:6.5px;color:#AAA}
|
||||
.ca-file-rm{font-size:10px;color:#B0ADA6;padding:0 4px;cursor:pointer}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* ── CONCEPT B — Split-panel + file switcher ── */
|
||||
/* ───────────────────────────────────────────── */
|
||||
.cb-top-bar{height:38px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:10px}
|
||||
.cb-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||
.cb-title{font-family:'Merriweather',Georgia,serif;font-size:9px;font-weight:700;color:#002850}
|
||||
.cb-count{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:10px;font-size:7px;font-weight:800;letter-spacing:.3px}
|
||||
.cb-discard{margin-left:auto;font-size:7px;font-weight:700;color:#C0392B;letter-spacing:.2px}
|
||||
|
||||
.cb-split{display:flex;min-height:440px}
|
||||
.cb-pdf{flex:55;background:#5E5C59;display:flex;flex-direction:column;border-right:1px solid #3A3836}
|
||||
.cb-pdf-toolbar{height:28px;background:#3A3836;display:flex;align-items:center;padding:0 10px;gap:8px}
|
||||
.cb-pdf-btn{width:16px;height:16px;border-radius:2px;background:rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;font-size:7px;color:rgba(255,255,255,.6)}
|
||||
.cb-pdf-page{font-size:6.5px;color:rgba(255,255,255,.4);margin-left:auto;font-weight:700;letter-spacing:.5px}
|
||||
.cb-pdf-view{flex:1;display:flex;justify-content:center;padding:14px;overflow:hidden}
|
||||
.cb-paper{background:#FFFEF8;box-shadow:0 2px 10px rgba(0,0,0,.3);border-radius:1px;padding:14px 16px;display:flex;flex-direction:column;gap:0;width:180px;flex-shrink:0}
|
||||
.pl{height:4px;background:#C4BDB0;border-radius:1px;opacity:.55;margin-bottom:3px}
|
||||
.pl.h{height:6px;opacity:.75;margin-bottom:5px}
|
||||
.pl.s{width:55%;opacity:.3}
|
||||
.pl.m{width:80%}
|
||||
.pl.sp{height:7px;background:transparent}
|
||||
.cb-filebar{background:#434140;border-top:1px solid #3A3836;display:flex;align-items:center;padding:0 8px;gap:3px;height:36px;flex-shrink:0}
|
||||
.cb-fb-arrow{width:18px;height:22px;border-radius:2px;background:rgba(255,255,255,.08);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,.6)}
|
||||
.cb-fb-track{flex:1;display:flex;gap:3px;padding:0 3px;overflow:hidden}
|
||||
.cb-fb-item{padding:3px 6px;border-radius:2px;font-size:6px;font-weight:700;color:rgba(255,255,255,.55);background:rgba(255,255,255,.06);display:flex;align-items:center;gap:4px;white-space:nowrap}
|
||||
.cb-fb-item.on{background:#A6DAD8;color:#002850}
|
||||
.cb-fb-num{background:rgba(0,0,0,.15);border-radius:2px;padding:0 3px;font-size:5.5px;font-weight:800}
|
||||
.cb-fb-item.on .cb-fb-num{background:rgba(0,40,80,.25);color:#002850}
|
||||
|
||||
.cb-form{flex:45;background:#fff;display:flex;flex-direction:column}
|
||||
.cb-form-scroll{flex:1;overflow-y:auto;padding:14px}
|
||||
|
||||
.cb-only-card{background:#F0FAFA;border:1px solid #A6DAD8;border-radius:3px;padding:10px 12px;margin-bottom:12px}
|
||||
.cb-only-head{display:flex;align-items:center;gap:6px;margin-bottom:7px}
|
||||
.cb-only-badge{background:#005858;color:#A6DAD8;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cb-only-subtitle{font-size:6.5px;color:#005858;font-weight:600;letter-spacing:.3px}
|
||||
|
||||
.cb-shared-card{background:#F9F8F5;border:1px solid #E4E2D7;border-radius:3px;padding:10px 12px;margin-bottom:10px}
|
||||
.cb-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:9px}
|
||||
.cb-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cb-shared-subtitle{font-size:6.5px;color:#002850;font-weight:600}
|
||||
.cb-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:7px}
|
||||
.cb-row.full{grid-template-columns:1fr}
|
||||
.cb-field{display:flex;flex-direction:column;gap:3px}
|
||||
|
||||
/* ─────────────────────────────────────── */
|
||||
/* ── CONCEPT C — Progressive accordion ── */
|
||||
/* ─────────────────────────────────────── */
|
||||
.cc-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px}
|
||||
|
||||
.cc-body{background:#ECEAE4;padding:14px;display:flex;flex-direction:column;gap:11px;max-height:540px;overflow-y:auto}
|
||||
|
||||
.cc-shared{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.03);position:sticky;top:0;z-index:2}
|
||||
.cc-shared-head{display:flex;align-items:center;gap:7px;margin-bottom:11px}
|
||||
.cc-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cc-shared-title{font-family:'Merriweather',Georgia,serif;font-size:10px;color:#002850;font-weight:700}
|
||||
.cc-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px 10px}
|
||||
.cc-grid .span2{grid-column:span 2}
|
||||
|
||||
.cc-files-label{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6;padding:0 2px;margin-top:6px}
|
||||
|
||||
.cc-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;overflow:hidden}
|
||||
.cc-file.open{border-color:#002850;box-shadow:0 2px 6px rgba(0,40,80,.08)}
|
||||
.cc-file-head{display:flex;align-items:center;gap:10px;padding:9px 12px;cursor:pointer}
|
||||
.cc-file-head.open{border-bottom:1px solid #E4E2D7;background:#F9F8F5}
|
||||
.cc-caret{font-size:9px;color:#A6DAD8;width:10px}
|
||||
.cc-file-thumb{width:22px;height:28px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;padding:2px;display:flex;flex-direction:column;gap:1px;flex-shrink:0}
|
||||
.cc-file-thumb .tl{height:2px;background:#C4BDB0;opacity:.55;border-radius:1px}
|
||||
.cc-file-body{flex:1;min-width:0}
|
||||
.cc-file-titlerow{display:flex;align-items:center;gap:7px}
|
||||
.cc-file-title{font-size:8.5px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cc-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
|
||||
.cc-file-meta{font-size:6.5px;color:#AAA;margin-top:2px}
|
||||
.cc-file-rm{font-size:11px;color:#B0ADA6;padding:0 4px}
|
||||
|
||||
.cc-file-open{display:flex;background:#F5F4EE}
|
||||
.cc-preview{flex:45;background:#5E5C59;padding:12px;display:flex;justify-content:center}
|
||||
.cc-preview-paper{background:#FFFEF8;border-radius:1px;padding:8px 10px;width:110px;flex-shrink:0;display:flex;flex-direction:column;box-shadow:0 2px 6px rgba(0,0,0,.25)}
|
||||
.cc-file-form{flex:55;padding:12px 14px;background:#fff;display:flex;flex-direction:column;gap:7px}
|
||||
|
||||
/* ─────────── Decision matrix ─────────── */
|
||||
.decision{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;margin:88px 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.decision h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:6px}
|
||||
.decision p.lead{font-size:13.5px;color:#555;line-height:1.7;margin-bottom:22px;max-width:820px}
|
||||
.dm{width:100%;border-collapse:collapse;margin-top:12px;font-size:12px}
|
||||
.dm th{text-align:left;font-size:9.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#002850;padding:9px 12px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
|
||||
.dm td{padding:13px 12px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.6}
|
||||
.dm td:first-child{font-weight:700;color:#002850;width:18%;white-space:nowrap}
|
||||
.dm td.score{font-size:15px;text-align:center;width:12%}
|
||||
.dm td.ok{color:#1A7040}
|
||||
.dm td.mid{color:#A07100}
|
||||
.dm td.bad{color:#C0392B}
|
||||
|
||||
/* ─────────── Recommendation ─────────── */
|
||||
.reco{background:#002850;color:#fff;border-radius:6px;padding:36px 40px;margin:48px 0 64px;box-shadow:0 4px 20px rgba(0,40,80,.15)}
|
||||
.reco .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
|
||||
.reco h2{font-family:'Merriweather',Georgia,serif;font-size:26px;font-weight:700;margin-top:6px}
|
||||
.reco .why{font-size:13.5px;line-height:1.85;color:rgba(255,255,255,.88);max-width:780px;margin-top:14px}
|
||||
.reco ul{list-style:none;margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:9px 26px}
|
||||
.reco ul li{font-size:12.5px;color:rgba(255,255,255,.9);padding-left:22px;position:relative;line-height:1.6}
|
||||
.reco ul li::before{content:"✓";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
|
||||
/* ─────────── Impl-ref ─────────── */
|
||||
.impl{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.impl h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:16px}
|
||||
.impl h3{font-family:'Merriweather',Georgia,serif;font-size:15px;color:#002850;margin:22px 0 10px}
|
||||
.impl-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:12px}
|
||||
.impl-table th{text-align:left;font-size:9px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:#002850;padding:8px 10px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
|
||||
.impl-table td{padding:10px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.55}
|
||||
.impl-table td:first-child{font-weight:700;color:#002850;width:22%}
|
||||
.impl-table td code{font-family:'SF Mono','Menlo',monospace;font-size:11px;background:#F0EEE8;padding:1px 6px;border-radius:2px;color:#002850}
|
||||
.impl-table td.px{color:#777;font-size:11.5px;width:16%}
|
||||
.impl-table td.note{color:#888;font-size:11.5px;font-style:italic;width:22%}
|
||||
.impl h3.ix{margin-top:32px}
|
||||
|
||||
.notes{background:#F9F8F5;border-left:3px solid #A6DAD8;padding:16px 22px;border-radius:0 4px 4px 0;margin-top:26px}
|
||||
.notes .nh{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#002850;margin-bottom:8px}
|
||||
.notes ul{list-style:none;display:flex;flex-direction:column;gap:6px}
|
||||
.notes li{font-size:12px;color:#333;padding-left:18px;position:relative;line-height:1.7}
|
||||
.notes li::before{content:"•";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═══════════════ MASTHEAD ══════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="mh">
|
||||
<div class="kicker">UX Spec · Bulk Upload</div>
|
||||
<h1>Uploading multiple documents in a single pass</h1>
|
||||
<p>
|
||||
Extends issue <strong>#294</strong> (new-document split-panel) with bulk uploads. When a user drops
|
||||
N files, every metadata field applies once to all of them — only the <em>title</em> is per-file,
|
||||
pre-filled from the filename and editable inline. A single save POST creates N documents.
|
||||
</p>
|
||||
<div class="byline">Prepared by Leonie Voss · 2026-04-24 · Draft 1 · References: #294, #305</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">feature</span>
|
||||
<span class="tag mint">ui</span>
|
||||
<span class="tag gray">a11y 320px+</span>
|
||||
<span class="tag green">backend ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goals -->
|
||||
<div class="goals">
|
||||
<h2>Design goals</h2>
|
||||
<ul>
|
||||
<li><strong>One-pass feel</strong>: drop → fill shared fields → save. No wizard, no per-file detour.</li>
|
||||
<li><strong>Every field is shared except the title</strong>, which is always set (filename-derived).</li>
|
||||
<li><strong>No mode switch</strong>: 1 file and N files use the same screen — more files reveal more chrome.</li>
|
||||
<li><strong>Scales to 20+ files</strong> without the form losing scan-ability on mobile.</li>
|
||||
<li><strong>Reuses the #294 split-panel layout</strong> (DocumentEditLayout) — minimum new surface.</li>
|
||||
<li><strong>a11y-first</strong>: 44px targets, focus states, `aria-current` on active file, keyboard-navigable.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═════════ CONCEPT A — STACK ═════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">A</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept A</div>
|
||||
<div class="concept-title">Flat Stack — shared header · file cards · sticky save</div>
|
||||
<p class="concept-desc">
|
||||
A single vertical flow: drop zone on top, then a <em>Gilt für alle</em> metadata card,
|
||||
then stacked file cards (thumbnail · editable title · remove). No split panel, no tabs.
|
||||
Scrolling down reveals all files; the save bar sticks to the bottom.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">Small-screen workflows. Seniors who prefer linear flows over tabs.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: no PDF preview until you click through to the document after save. Harder to verify
|
||||
you grabbed the right files before committing.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mobile mockup -->
|
||||
<div class="screen narrow">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">375 · mobile</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-nav-r">
|
||||
<div class="app-avatar">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ca-top-bar">
|
||||
<div class="ca-back">← Zurück</div>
|
||||
<div class="ca-title">Neue Dokumente</div>
|
||||
<div class="ca-count">5</div>
|
||||
</div>
|
||||
<div class="ca-body" style="height:500px">
|
||||
<!-- drop zone -->
|
||||
<div class="ca-drop">
|
||||
<div class="ca-drop-icon">⇪</div>
|
||||
<div class="ca-drop-title">Weitere Dateien hinzufügen</div>
|
||||
<div class="ca-drop-sub">PDF, JPEG, PNG, TIFF · max 50 MB</div>
|
||||
</div>
|
||||
|
||||
<!-- shared card -->
|
||||
<div class="ca-shared-card">
|
||||
<div class="ca-shared-head">
|
||||
<span class="ca-shared-badge">Gilt für alle 5</span>
|
||||
<span class="ca-shared-title">Angaben</span>
|
||||
</div>
|
||||
<div class="ca-shared-grid">
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Absender</span>
|
||||
<div class="f-input filled">Hans Müller</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Empfänger</span>
|
||||
<div class="f-input filled">Anna Schmidt</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Datum</span>
|
||||
<div class="f-input filled">1950-06</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Ort</span>
|
||||
<div class="f-input empty">Berlin</div>
|
||||
</div>
|
||||
<div class="ca-shared-field full">
|
||||
<span class="f-label">Tags</span>
|
||||
<div class="f-tags">
|
||||
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- files list -->
|
||||
<div class="ca-files-head">
|
||||
<div class="ca-files-title">5 Dateien · Titel bearbeiten</div>
|
||||
</div>
|
||||
<div class="ca-file active">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl m"></div><div class="tl s"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1940_Hans</div>
|
||||
<div class="ca-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl"></div><div class="tl s"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1940_Anna</div>
|
||||
<div class="ca-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl m"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1941_Clara</div>
|
||||
<div class="ca-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl s"></div><div class="tl m"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title placeholder">Postkarte_Venedig</div>
|
||||
<div class="ca-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═══ CONCEPT B — SPLIT-PANEL + SWITCHER ══ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">B</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept B · RECOMMENDED</div>
|
||||
<div class="concept-title">Split-Panel with File Switcher</div>
|
||||
<p class="concept-desc">
|
||||
Reuses the <em>DocumentEditLayout</em> from issue #294 and adds a horizontal file-switcher strip
|
||||
under the PDF preview. Right column splits into two cards: <em>Gilt nur für diese Datei</em>
|
||||
(title only, mint accent) and <em>Gilt für alle N Dokumente</em> (everything else).
|
||||
When N=1 the switcher disappears and the screen is byte-identical to #294.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">The project's primary use case. Desktop + tablet, matches #294 DNA.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: on mobile the split has to collapse into tabs ("Vorschau / Angaben"). We reuse the
|
||||
same responsive pattern that DocumentEditLayout already ships with.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- desktop mockup -->
|
||||
<div class="screen">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">1280 · desktop</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-link on">Dokumente</div>
|
||||
<div class="app-link">Personen</div>
|
||||
<div class="app-link">Briefwechsel</div>
|
||||
<div class="app-link">Chronik</div>
|
||||
<div class="app-nav-r">
|
||||
<div class="app-avatar">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-top-bar">
|
||||
<div class="cb-back">← Dokumente</div>
|
||||
<div class="cb-title">Neue Dokumente</div>
|
||||
<div class="cb-count">5 werden erstellt</div>
|
||||
<div class="cb-discard">Alle verwerfen</div>
|
||||
</div>
|
||||
|
||||
<div class="cb-split">
|
||||
<!-- PDF side -->
|
||||
<div class="cb-pdf">
|
||||
<div class="cb-pdf-toolbar">
|
||||
<div class="cb-pdf-btn">◀</div>
|
||||
<div class="cb-pdf-btn">▶</div>
|
||||
<div class="cb-pdf-btn">+</div>
|
||||
<div class="cb-pdf-btn">−</div>
|
||||
<div class="cb-pdf-page">Seite 1 / 2 · Datei 1 von 5</div>
|
||||
</div>
|
||||
<div class="cb-pdf-view">
|
||||
<div class="cb-paper">
|
||||
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
|
||||
<div class="pl s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
|
||||
<div class="pl"></div><div class="pl s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- file switcher -->
|
||||
<div class="cb-filebar">
|
||||
<div class="cb-fb-arrow">‹</div>
|
||||
<div class="cb-fb-track">
|
||||
<div class="cb-fb-item on"><span class="cb-fb-num">1</span> Brief_1940_Hans.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">2</span> Brief_1940_Anna.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">3</span> Brief_1941_Clara.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">4</span> Postkarte_Venedig.jpg</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">5</span> Urkunde_1942.pdf</div>
|
||||
</div>
|
||||
<div class="cb-fb-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form side -->
|
||||
<div class="cb-form">
|
||||
<div class="cb-form-scroll">
|
||||
<!-- PER-FILE card -->
|
||||
<div class="cb-only-card">
|
||||
<div class="cb-only-head">
|
||||
<span class="cb-only-badge">Nur diese Datei</span>
|
||||
<span class="cb-only-subtitle">1 / 5 · Brief_1940_Hans.pdf</span>
|
||||
</div>
|
||||
<div class="cb-row full">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Titel <span class="f-req">*</span></span>
|
||||
<div class="f-input filled tall">Brief an Anna, 1940</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SHARED card -->
|
||||
<div class="cb-shared-card">
|
||||
<div class="cb-shared-head">
|
||||
<span class="cb-shared-badge">Gilt für alle 5</span>
|
||||
<span class="cb-shared-subtitle">Gemeinsame Angaben</span>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Absender</span>
|
||||
<div class="f-input filled">Hans Müller</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Empfänger</span>
|
||||
<div class="f-input filled">Anna Schmidt</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Datum</span>
|
||||
<div class="f-input filled">15.06.1950</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Ort</span>
|
||||
<div class="f-input empty">z.B. Berlin</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row full">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Tags</span>
|
||||
<div class="f-tags">
|
||||
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Briefwechsel <span class="f-chip-rm">×</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Archivbox</span>
|
||||
<div class="f-input empty">z.B. B-12</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Mappe</span>
|
||||
<div class="f-input empty">z.B. M-3</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary green">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══ CONCEPT C — PROGRESSIVE ACCORDION ══ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">C</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept C</div>
|
||||
<div class="concept-title">Progressive Accordion — shared sticky header · file cards expand inline</div>
|
||||
<p class="concept-desc">
|
||||
Shared metadata sticks at the top of the page. Below, each file is a collapsed card; clicking
|
||||
a card expands it to show the PDF preview + title field inline. Only one card is expanded at a
|
||||
time. Scales well to 20+ files — the list stays readable, you only look at the PDFs you want
|
||||
to verify.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">Large batches (10+ files) where you want to spot-check a few.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: two different visual languages — cards collapsed vs. cards expanded with PDF. New
|
||||
pattern for the project; costs familiarity.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screen">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">1280 · desktop</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-link on">Dokumente</div>
|
||||
<div class="app-link">Personen</div>
|
||||
<div class="app-nav-r"><div class="app-avatar">MR</div></div>
|
||||
</div>
|
||||
<div class="cc-top-bar">
|
||||
<div class="ca-back">← Zurück</div>
|
||||
<div class="ca-title">Neue Dokumente</div>
|
||||
<div class="ca-count">5</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-body">
|
||||
<!-- sticky shared card -->
|
||||
<div class="cc-shared">
|
||||
<div class="cc-shared-head">
|
||||
<span class="cc-shared-badge">Gilt für alle 5</span>
|
||||
<span class="cc-shared-title">Gemeinsame Angaben</span>
|
||||
</div>
|
||||
<div class="cc-grid">
|
||||
<div class="cb-field"><span class="f-label">Absender</span><div class="f-input filled">Hans Müller</div></div>
|
||||
<div class="cb-field"><span class="f-label">Empfänger</span><div class="f-input filled">Anna Schmidt</div></div>
|
||||
<div class="cb-field"><span class="f-label">Datum</span><div class="f-input filled">15.06.1950</div></div>
|
||||
<div class="cb-field span2"><span class="f-label">Tags</span><div class="f-tags"><span class="f-chip">Familie <span class="f-chip-rm">×</span></span><span class="f-chip">Krieg <span class="f-chip-rm">×</span></span></div></div>
|
||||
<div class="cb-field"><span class="f-label">Ort</span><div class="f-input empty">z.B. Berlin</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-files-label">5 Dateien</div>
|
||||
|
||||
<!-- collapsed card -->
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title">Brief an Anna, 1940</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- expanded card -->
|
||||
<div class="cc-file open">
|
||||
<div class="cc-file-head open">
|
||||
<div class="cc-caret" style="color:#002850">▾</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title">Brief von Anna, Antwort</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="cc-file-open">
|
||||
<div class="cc-preview">
|
||||
<div class="cc-preview-paper">
|
||||
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
|
||||
<div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl"></div><div class="pl m"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file-form">
|
||||
<div class="cb-only-head">
|
||||
<span class="cb-only-badge">Nur diese Datei</span>
|
||||
<span class="cb-only-subtitle">2 / 5</span>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Titel <span class="f-req">*</span></span>
|
||||
<div class="f-input filled tall">Brief von Anna, Antwort</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- more collapsed -->
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Brief_1941_Clara</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Postkarte_Venedig</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Urkunde_1942</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Urkunde_1942.pdf · 3.1 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary green">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ DECISION MATRIX ════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="decision">
|
||||
<h2>Decision matrix</h2>
|
||||
<p class="lead">
|
||||
All three concepts meet the core requirement (shared metadata + per-file title + one save).
|
||||
Graded against what matters for the senior audience, the responsive constraint, and the #294
|
||||
architectural commitment.
|
||||
</p>
|
||||
<table class="dm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dimension</th>
|
||||
<th>A · Stack</th>
|
||||
<th>B · Split-Panel</th>
|
||||
<th>C · Accordion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Reuses #294 layout</td>
|
||||
<td class="score bad">✕</td>
|
||||
<td class="score ok">✓</td>
|
||||
<td class="score bad">✕</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Single-file mode unchanged</td>
|
||||
<td class="score mid">rewrite</td>
|
||||
<td class="score ok">identical</td>
|
||||
<td class="score bad">different</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PDF visible before save</td>
|
||||
<td class="score bad">no</td>
|
||||
<td class="score ok">always</td>
|
||||
<td class="score mid">one at a time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Works at 320px</td>
|
||||
<td class="score ok">native</td>
|
||||
<td class="score mid">via tab collapse</td>
|
||||
<td class="score ok">native</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scales to 20 files</td>
|
||||
<td class="score mid">long scroll</td>
|
||||
<td class="score ok">switcher scrolls</td>
|
||||
<td class="score ok">collapsed list</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>New Svelte components</td>
|
||||
<td class="score bad">3 new</td>
|
||||
<td class="score ok">1 new (switcher)</td>
|
||||
<td class="score bad">4 new</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Familiar pattern</td>
|
||||
<td class="score ok">yes</td>
|
||||
<td class="score ok">yes (post-#294)</td>
|
||||
<td class="score mid">new to app</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ RECOMMENDATION ════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="reco">
|
||||
<div class="kicker">Recommendation</div>
|
||||
<h2>Ship Concept B</h2>
|
||||
<p class="why">
|
||||
Concept B treats bulk upload as a <em>polymorphic state</em> of the existing single-document
|
||||
layout rather than a separate screen. A user who drops one file gets exactly the #294 experience.
|
||||
A user who drops five gets the same screen plus a horizontal file-switcher and a two-card split
|
||||
(<em>Nur diese Datei</em> vs. <em>Gilt für alle</em>). Nothing about the single-file flow changes.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Keeps the mental model: "one form, one save" regardless of file count.</li>
|
||||
<li>PDF preview is persistent — you can spot-check each scan before committing.</li>
|
||||
<li>The per-file title is visually promoted with a mint border so it reads as the one thing that differs per file.</li>
|
||||
<li>Reuses DocumentEditLayout: the delta is ~1 new component (<code>FileSwitcherStrip</code>) + two cards in the form.</li>
|
||||
<li>Single-file mode is byte-identical to #294 — no regression risk for existing users.</li>
|
||||
<li>Backend is already ready (<code>POST /api/documents/quick-upload</code> accepts N files in one multipart).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ IMPL-REF · CONCEPT B ═══════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="impl">
|
||||
<h2>Implementation reference — Concept B</h2>
|
||||
|
||||
<h3>Top bar (when N > 1)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Count pill "N werden erstellt"</td>
|
||||
<td><code>bg-accent text-primary rounded-full px-3 py-1 text-sm font-bold</code></td>
|
||||
<td class="px">14px · 700</td>
|
||||
<td class="note">brand-mint on brand-navy</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"Alle verwerfen" link</td>
|
||||
<td><code>ml-auto text-sm font-bold text-red-600 hover:text-red-800 focus-visible:outline-2 focus-visible:outline-red-600</code></td>
|
||||
<td class="px">14px / 44px target</td>
|
||||
<td class="note">confirm dialog before wiping</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">FileSwitcherStrip (new component)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Strip container</td>
|
||||
<td><code>flex items-center gap-1 bg-ink/95 px-2 py-2 border-t border-ink/80</code></td>
|
||||
<td class="px">height 48px</td>
|
||||
<td class="note">under the PDF toolbar, on the dark panel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Arrow buttons</td>
|
||||
<td><code>h-10 w-10 rounded-sm bg-white/8 text-surface/60 hover:bg-white/15 focus-visible:outline-2</code></td>
|
||||
<td class="px">40×40 (44 w/padding)</td>
|
||||
<td class="note"><code>aria-label="Vorherige Datei"</code> / "Nächste Datei"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File chip (inactive)</td>
|
||||
<td><code>px-3 py-2 rounded-sm bg-white/6 text-sm font-bold text-surface/55 whitespace-nowrap hover:bg-white/12</code></td>
|
||||
<td class="px">14px / h 40px</td>
|
||||
<td class="note">horizontal scroll container uses <code>snap-x snap-mandatory</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File chip (active)</td>
|
||||
<td><code>... bg-accent text-primary</code> + <code>aria-current="true"</code></td>
|
||||
<td class="px">14px / h 40px</td>
|
||||
<td class="note">mint pill, primary text — 7.2:1 contrast passes AAA</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Chip number prefix</td>
|
||||
<td><code>bg-primary/25 rounded-sm px-1 mr-2 text-xs font-extrabold</code></td>
|
||||
<td class="px">12px / 800</td>
|
||||
<td class="note">"1", "2", … — for quick scanning</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">"Nur diese Datei" card (per-file scope)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Card container</td>
|
||||
<td><code>bg-accent/20 border border-accent rounded-sm p-4 mb-4</code></td>
|
||||
<td class="px">padding 16px</td>
|
||||
<td class="note">mint tint signals "different per file"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scope badge</td>
|
||||
<td><code>bg-primary/90 text-accent rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
|
||||
<td class="px">12px · 800</td>
|
||||
<td class="note">Paraglide key: <code>bulk_only_this_file</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Title input</td>
|
||||
<td><code>h-11 text-base font-semibold text-ink bg-white border border-line rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20</code></td>
|
||||
<td class="px">44px min-height · 16px</td>
|
||||
<td class="note">pre-filled from filename <em>without extension</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">"Gilt für alle" card (shared scope)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Card container</td>
|
||||
<td><code>bg-surface border border-line rounded-sm p-4 mb-3</code></td>
|
||||
<td class="px">padding 16px</td>
|
||||
<td class="note">neutral (no accent tint)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scope badge</td>
|
||||
<td><code>bg-accent text-primary rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
|
||||
<td class="px">12px · 800</td>
|
||||
<td class="note">Paraglide: <code>bulk_shared_count</code> ("Gilt für alle {count}")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Field grid</td>
|
||||
<td><code>grid grid-cols-1 md:grid-cols-2 gap-3</code></td>
|
||||
<td class="px">12px gap</td>
|
||||
<td class="note">single column at 320px, two at ≥ 768px</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">Save bar</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Primary save button</td>
|
||||
<td><code>h-11 px-5 bg-green-700 hover:bg-green-800 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900</code></td>
|
||||
<td class="px">44px min · 14px</td>
|
||||
<td class="note">label <code>{count} speichern →</code> (plural-aware Paraglide)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"Als Platzhalter" (outline)</td>
|
||||
<td><code>h-11 px-4 border border-line bg-white text-ink-3 font-bold rounded-sm text-sm</code></td>
|
||||
<td class="px">44px</td>
|
||||
<td class="note">posts with <code>metadataComplete=false</code> for all</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">Responsive collapse (≤ 767px)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Panel mode switch</td>
|
||||
<td>reuses DocumentEditLayout's existing tab collapse — "Vorschau / Angaben" tabs</td>
|
||||
<td class="px">tab height 48px</td>
|
||||
<td class="note">already shipped with #294</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File switcher stays on "Vorschau" tab</td>
|
||||
<td><code>snap-x snap-mandatory overflow-x-auto</code></td>
|
||||
<td class="px">h 44px</td>
|
||||
<td class="note">horizontal swipe; arrow buttons removed at mobile</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="notes">
|
||||
<div class="nh">Interactions + behaviour</div>
|
||||
<ul>
|
||||
<li><strong>Drop a file after the initial batch</strong>: append to the end of the list and switch focus to the newly added file. No modal, no confirmation.</li>
|
||||
<li><strong>Remove a file</strong> (X on the chip) → confirm only if it's the currently-previewed one; otherwise silent. When count drops to 1 the switcher strip animates away (200ms); when it drops to 0 we redirect back to the drop-zone state.</li>
|
||||
<li><strong>Title auto-fill</strong>: <code>filename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim()</code>. Marks the title input as <code>suggested</code> until the user edits it (mint left border, same treatment as #294's filename-derived fields).</li>
|
||||
<li><strong>Title field visibility</strong>: always rendered (never collapsed) even in single-file mode, so there's zero layout jump when N changes from 1 to 2.</li>
|
||||
<li><strong>Save flow</strong>: single POST to <code>/api/documents/quick-upload</code> with N files + JSON metadata object containing shared fields + titles array. Backend maps title[i] to files[i] by index. Response splits into <code>created[] / updated[] / errors[]</code> — show a summary toast + inline error markers per file for the <code>errors[]</code> list.</li>
|
||||
<li><strong>Keyboard navigation</strong>: <kbd>←</kbd>/<kbd>→</kbd> on the switcher strip moves file focus; <kbd>Tab</kbd> cycles through form fields inside whichever card is active; <kbd>Esc</kbd> on the discard button opens the confirm dialog.</li>
|
||||
<li><strong>Focus management on file switch</strong>: when the user clicks a different file, the title input of the new file receives focus automatically (so the main editable field is always reachable).</li>
|
||||
<li><strong>Progress indicator during save</strong>: replace the save button with a determinate progress bar showing "Lade Datei 3 von 5…" for batches that take > 500ms.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="margin-top:14px;border-left-color:#C0392B">
|
||||
<div class="nh" style="color:#C0392B">Edge cases + a11y</div>
|
||||
<ul>
|
||||
<li><strong>Duplicate filenames in the batch</strong>: accept, but show a warning icon next to both — backend will create both with unique IDs.</li>
|
||||
<li><strong>Mixed content types</strong>: PDF + image in the same batch is fine; the preview panel renders whichever the active file is (DocumentEditLayout already handles both).</li>
|
||||
<li><strong>Large batches (> 20 files)</strong>: the switcher strip becomes scrollable; consider a "Jump to file…" combobox at > 30 files (out of scope for v1).</li>
|
||||
<li><strong>Upload failure per file</strong>: mark the chip red (<code>bg-red-600/20 text-red-800 border border-red-600</code>), show inline error in the chip's tooltip, don't block the rest of the batch from retrying.</li>
|
||||
<li><strong>Screen reader announcement</strong>: when file count changes, fire a polite live region announce — "5 Dateien bereit zum Speichern" via <code>role="status" aria-live="polite"</code>.</li>
|
||||
<li><strong>Colour-alone warning</strong>: active file chip uses color + <code>aria-current="true"</code> + a ▸ caret prefix so it's distinguishable for color-blind users.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1684
docs/specs/bulk-upload-split-panel-spec.html
Normal file
1684
docs/specs/bulk-upload-split-panel-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
2043
docs/specs/chronik-spec.html
Normal file
2043
docs/specs/chronik-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
577
docs/specs/enrichment-list-block-spec.html
Normal file
577
docs/specs/enrichment-list-block-spec.html
Normal file
@@ -0,0 +1,577 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Ergänzungs-Liste — Dashboard-Block · Spec (Issue t.b.d.)</title>
|
||||
<style>
|
||||
:root{
|
||||
--navy:#002850;--mint:#A6DAD8;--sand:#E4E2D7;
|
||||
--surface:#FAFAF7;--bg:#E8E7E2;--border:#D8D7D0;
|
||||
--text:#1C1C18;--muted:#6B6A63;--subtle:#9B9A93;
|
||||
--orange:#C26A00;--orange-bg:#FEF4E2;
|
||||
--green:#2E6E39;--green-bg:#EAF5EA;
|
||||
--red:#A83232;--red-bg:#FCEBEB;
|
||||
--font:system-ui,sans-serif;--mono:'Courier New',monospace;
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1100px;margin:0 auto;padding:48px 32px 96px;}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:48px 0;}
|
||||
|
||||
/* Header */
|
||||
.hdr{background:var(--navy);color:#fff;padding:32px 32px 28px;border-radius:8px 8px 0 0;}
|
||||
.hdr h1{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;}
|
||||
.hdr-meta{font-family:var(--mono);font-size:11px;color:rgba(255,255,255,.45);margin-top:10px;}
|
||||
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.05em;background:var(--mint);color:var(--navy);}
|
||||
.badge-g{background:rgba(255,255,255,.15);color:rgba(255,255,255,.9);}
|
||||
.badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
|
||||
.decision-box{background:#fff;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:20px 28px 24px;margin-bottom:40px;}
|
||||
.decision-box h2{font-family:Georgia,serif;font-size:16px;font-weight:400;color:var(--navy);margin-bottom:8px;}
|
||||
.prose{font-size:13px;color:var(--muted);line-height:1.65;max-width:720px;margin-bottom:10px;}
|
||||
.prose:last-child{margin-bottom:0;}
|
||||
.prose strong{color:var(--text);}
|
||||
|
||||
/* Sections */
|
||||
.sec{margin-bottom:52px;}
|
||||
.sec-label{font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:22px;}
|
||||
.sec-title{font-family:Georgia,serif;font-size:20px;font-weight:400;color:var(--navy);margin-bottom:4px;}
|
||||
.sec-sub{font-size:13px;color:var(--muted);margin-bottom:20px;max-width:740px;}
|
||||
|
||||
/* Callout */
|
||||
.callout{display:flex;gap:12px;padding:14px 16px;border-radius:4px;margin-bottom:16px;font-size:12px;line-height:1.55;}
|
||||
.callout.orange{background:var(--orange-bg);border-left:3px solid var(--orange);}
|
||||
.callout.green{background:var(--green-bg);border-left:3px solid var(--green);}
|
||||
.callout.navy{background:rgba(0,40,80,.05);border-left:3px solid var(--navy);}
|
||||
.callout.red{background:var(--red-bg);border-left:3px solid var(--red);}
|
||||
.callout strong{font-weight:700;}
|
||||
|
||||
/* Mock frame wrappers — scaled visual mockups (~55% of real) */
|
||||
.frames-row{display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start;margin-bottom:20px;}
|
||||
.frame-caption{font-family:var(--mono);font-size:10px;color:var(--muted);display:block;margin-top:8px;max-width:100%;}
|
||||
|
||||
/* ========== MOBILE MOCK (176px wide = 320 × .55) ========== */
|
||||
.m-phone{width:176px;background:#fff;border:6px solid #2A2A2A;border-radius:22px;overflow:hidden;box-shadow:0 6px 20px rgba(0,0,0,.15);}
|
||||
.m-status{height:11px;background:#000;}
|
||||
.m-body{background:var(--surface);padding:7px;}
|
||||
|
||||
/* Eyebrow */
|
||||
.mb-eyebrow{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;padding:0 2px;}
|
||||
.mb-eyebrow-l{font-size:6px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
|
||||
.mb-eyebrow-count{background:var(--navy);color:#fff;font-size:6px;font-weight:700;padding:1px 5px;border-radius:8px;}
|
||||
|
||||
.mb-block{background:#fff;border:1px solid var(--sand);border-radius:3px;overflow:hidden;}
|
||||
.mb-row{display:flex;align-items:center;padding:6px 7px;gap:5px;border-bottom:1px solid var(--sand);}
|
||||
.mb-row:last-child{border-bottom:none;}
|
||||
.mb-icon{width:14px;height:14px;background:rgba(0,40,80,.06);border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;color:var(--navy);flex-shrink:0;}
|
||||
.mb-icon.pdf{background:rgba(168,50,50,.12);color:var(--red);}
|
||||
.mb-icon.jpg{background:rgba(46,110,57,.12);color:var(--green);}
|
||||
.mb-main{flex:1;min-width:0;}
|
||||
.mb-title{font-family:Georgia,serif;font-size:7px;color:var(--navy);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.mb-meta{font-size:5.5px;color:var(--muted);margin-top:1px;}
|
||||
.mb-chev{color:var(--subtle);font-size:8px;flex-shrink:0;}
|
||||
|
||||
.mb-footer{padding:5px 7px;border-top:1px solid var(--sand);background:rgba(0,40,80,.02);text-align:center;}
|
||||
.mb-footer-link{font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--navy);}
|
||||
|
||||
/* ========== DESKTOP MOCK (640px wide = ~1160 content-col × .55) ========== */
|
||||
.d-frame{width:640px;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:14px;}
|
||||
.d-eyebrow{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}
|
||||
.d-eyebrow-l{font-size:9px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
|
||||
.d-eyebrow-count{background:var(--navy);color:#fff;font-size:9px;font-weight:700;padding:2px 8px;border-radius:10px;}
|
||||
|
||||
.d-block{background:#fff;border:1px solid var(--sand);border-radius:4px;overflow:hidden;}
|
||||
.d-row{display:flex;align-items:center;padding:9px 14px;gap:10px;border-bottom:1px solid var(--sand);cursor:pointer;}
|
||||
.d-row:last-child{border-bottom:none;}
|
||||
.d-row:hover{background:rgba(166,218,216,.08);}
|
||||
.d-row.focused{background:rgba(166,218,216,.08);box-shadow:inset 0 0 0 2px var(--navy);}
|
||||
.d-icon{width:20px;height:20px;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;}
|
||||
.d-icon.pdf{background:rgba(168,50,50,.12);color:var(--red);}
|
||||
.d-icon.jpg{background:rgba(46,110,57,.12);color:var(--green);}
|
||||
.d-icon.tif{background:rgba(91,94,166,.12);color:#5B5EA6;}
|
||||
.d-main{flex:1;min-width:0;}
|
||||
.d-title{font-family:Georgia,serif;font-size:12px;color:var(--navy);line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.d-meta{font-size:9px;color:var(--muted);margin-top:2px;}
|
||||
.d-chev{color:var(--subtle);font-size:14px;flex-shrink:0;}
|
||||
|
||||
.d-footer{padding:8px 14px;border-top:1px solid var(--sand);background:rgba(0,40,80,.02);display:flex;justify-content:flex-end;}
|
||||
.d-footer-link{font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--navy);}
|
||||
|
||||
/* Empty / not shown state */
|
||||
.empty-note{background:rgba(46,110,57,.06);border:1px dashed rgba(46,110,57,.25);border-radius:4px;padding:14px 16px;font-size:11px;color:var(--green);text-align:center;font-style:italic;}
|
||||
|
||||
/* After-upload banner */
|
||||
.banner{background:var(--navy);color:#fff;border-radius:4px;padding:10px 14px;display:flex;align-items:center;gap:10px;width:640px;margin-bottom:12px;}
|
||||
.banner-check{width:18px;height:18px;border-radius:50%;background:var(--mint);color:var(--navy);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;}
|
||||
.banner-t{font-size:11px;flex:1;}
|
||||
.banner-t strong{color:var(--mint);}
|
||||
.banner-cta{background:var(--mint);color:var(--navy);font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;padding:4px 10px;border-radius:3px;}
|
||||
.banner-x{color:rgba(255,255,255,.5);font-size:14px;cursor:pointer;padding:0 4px;}
|
||||
|
||||
/* Impl-ref table */
|
||||
.impl-ref{background:#fff;border:1px solid var(--border);border-radius:6px;overflow:hidden;margin-top:20px;}
|
||||
.impl-ref table{width:100%;border-collapse:collapse;}
|
||||
.impl-ref th{background:rgba(0,40,80,.04);font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 12px;text-align:left;border-bottom:1px solid var(--border);}
|
||||
.impl-ref td{font-size:11px;color:var(--text);padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top;line-height:1.55;}
|
||||
.impl-ref tr:last-child td{border-bottom:none;}
|
||||
.impl-ref td:first-child{font-weight:700;color:var(--navy);white-space:nowrap;}
|
||||
.impl-ref code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;color:var(--navy);}
|
||||
.impl-ref td.px{font-family:var(--mono);font-size:10px;color:var(--muted);white-space:nowrap;}
|
||||
.impl-ref td.note{font-size:10px;color:var(--muted);font-style:italic;}
|
||||
|
||||
/* A11y box */
|
||||
.a11y-box{background:rgba(91,94,166,.06);border-left:3px solid #5B5EA6;border-radius:0 4px 4px 0;padding:14px 18px;}
|
||||
.a11y-box h4{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#5B5EA6;margin-bottom:8px;}
|
||||
.a11y-box ul{list-style:none;padding-left:0;}
|
||||
.a11y-box li{font-size:12px;color:var(--text);padding-left:18px;position:relative;margin-bottom:6px;line-height:1.55;}
|
||||
.a11y-box li::before{content:'✓';position:absolute;left:0;color:#5B5EA6;font-weight:700;}
|
||||
.a11y-box code{font-family:var(--mono);font-size:10px;background:rgba(91,94,166,.1);padding:1px 4px;border-radius:2px;}
|
||||
|
||||
/* Two-col grid */
|
||||
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;}
|
||||
.two-col h3{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:6px;letter-spacing:.02em;}
|
||||
.two-col p{font-size:12px;color:var(--muted);line-height:1.55;}
|
||||
|
||||
/* Inline code */
|
||||
code.inline{font-family:var(--mono);font-size:11px;background:rgba(0,40,80,.06);padding:1px 5px;border-radius:3px;color:var(--navy);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ===== HEADER ===== -->
|
||||
<div class="hdr">
|
||||
<div class="badges">
|
||||
<span class="badge">DASHBOARD</span>
|
||||
<span class="badge badge-g">NEW BLOCK</span>
|
||||
<span class="badge badge-g">MOBILE-FIRST</span>
|
||||
</div>
|
||||
<h1>Ergänzungs-Liste — Dashboard-Block</h1>
|
||||
<div class="hdr-meta">Familienarchiv · 2026-04-20 · Leonie Voss, UX Lead</div>
|
||||
</div>
|
||||
|
||||
<div class="decision-box">
|
||||
<h2>The call</h2>
|
||||
<p class="prose">A dedicated list-block on the dashboard, placed <strong>between the Resume strip and the MissionControlStrip</strong>, surfaces the documents that were just uploaded and still need metadata. It replaces the orphaned <code class="inline">DashboardNeedsMetadata.svelte</code> component with a spec'd, senior-friendly row design.</p>
|
||||
<p class="prose"><strong>Why here, not a 4th column in MissionControlStrip:</strong> (1) strip is already at 3-column visual capacity; (2) batch-upload reality (10–15 docs arriving at once) makes a count tile useless — seniors need to see <em>which</em> docs are waiting; (3) placing it directly under the DropZone sightline makes upload→enrich the most visually-coupled pair on the page.</p>
|
||||
<p class="prose"><strong>Scope of this spec:</strong> the block itself (layout, row anatomy, empty/loading/error states, a11y, responsive behavior at 320/768/1440). The post-upload success banner is included as a coupled interaction. The strip redesign, pagination, and the other three pipeline stages (segment / transcribe / review) are <em>not</em> in scope — they earn their own specs.</p>
|
||||
</div>
|
||||
|
||||
<!-- ===== SECTION 1 — ANATOMY ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">01 — Block anatomy</div>
|
||||
<h2 class="sec-title">Three pieces: eyebrow, list, footer</h2>
|
||||
<p class="sec-sub">The block is a single <code class="inline"><section></code> landmark with an eyebrow heading, a divided list of up to 5 rows, and an optional footer link when more than 5 docs are pending. At <code class="inline">incompleteDocs.length === 0</code> the block renders <strong>nothing</strong> — no "all clear" state, no empty card. A clean dashboard means no work to do.</p>
|
||||
|
||||
<div class="frames-row">
|
||||
<div>
|
||||
<div class="m-phone">
|
||||
<div class="m-status"></div>
|
||||
<div class="m-body">
|
||||
<div class="mb-eyebrow">
|
||||
<span class="mb-eyebrow-l">Benötigen Metadaten</span>
|
||||
<span class="mb-eyebrow-count">12</span>
|
||||
</div>
|
||||
<div class="mb-block">
|
||||
<div class="mb-row">
|
||||
<div class="mb-icon pdf">PDF</div>
|
||||
<div class="mb-main">
|
||||
<div class="mb-title">Brief Oma Hilde 1962</div>
|
||||
<div class="mb-meta">vor 2 Min.</div>
|
||||
</div>
|
||||
<div class="mb-chev">›</div>
|
||||
</div>
|
||||
<div class="mb-row">
|
||||
<div class="mb-icon pdf">PDF</div>
|
||||
<div class="mb-main">
|
||||
<div class="mb-title">Geburtsurkunde Opa</div>
|
||||
<div class="mb-meta">vor 2 Min.</div>
|
||||
</div>
|
||||
<div class="mb-chev">›</div>
|
||||
</div>
|
||||
<div class="mb-row">
|
||||
<div class="mb-icon jpg">JPG</div>
|
||||
<div class="mb-main">
|
||||
<div class="mb-title">Foto Hochzeit Tante Elsa</div>
|
||||
<div class="mb-meta">vor 2 Min.</div>
|
||||
</div>
|
||||
<div class="mb-chev">›</div>
|
||||
</div>
|
||||
<div class="mb-row">
|
||||
<div class="mb-icon pdf">PDF</div>
|
||||
<div class="mb-main">
|
||||
<div class="mb-title">Postkarte Bodensee</div>
|
||||
<div class="mb-meta">vor 2 Min.</div>
|
||||
</div>
|
||||
<div class="mb-chev">›</div>
|
||||
</div>
|
||||
<div class="mb-row">
|
||||
<div class="mb-icon pdf">PDF</div>
|
||||
<div class="mb-main">
|
||||
<div class="mb-title">Meldebescheinigung 1971</div>
|
||||
<div class="mb-meta">vor 3 Min.</div>
|
||||
</div>
|
||||
<div class="mb-chev">›</div>
|
||||
</div>
|
||||
<div class="mb-footer">
|
||||
<span class="mb-footer-link">Alle 12 anzeigen →</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="frame-caption">320px · 12 docs pending · 5 shown, rest via footer link to /enrich</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="d-frame">
|
||||
<div class="d-eyebrow">
|
||||
<span class="d-eyebrow-l">Benötigen Metadaten</span>
|
||||
<span class="d-eyebrow-count">12</span>
|
||||
</div>
|
||||
<div class="d-block">
|
||||
<div class="d-row">
|
||||
<div class="d-icon pdf">PDF</div>
|
||||
<div class="d-main">
|
||||
<div class="d-title">Brief Oma Hilde 1962 — An meinen lieben Ludwig</div>
|
||||
<div class="d-meta">vor 2 Minuten hochgeladen · 3 Seiten</div>
|
||||
</div>
|
||||
<div class="d-chev">›</div>
|
||||
</div>
|
||||
<div class="d-row focused">
|
||||
<div class="d-icon pdf">PDF</div>
|
||||
<div class="d-main">
|
||||
<div class="d-title">Geburtsurkunde Opa Friedrich</div>
|
||||
<div class="d-meta">vor 2 Minuten hochgeladen · 1 Seite</div>
|
||||
</div>
|
||||
<div class="d-chev">›</div>
|
||||
</div>
|
||||
<div class="d-row">
|
||||
<div class="d-icon jpg">JPG</div>
|
||||
<div class="d-main">
|
||||
<div class="d-title">Foto Hochzeit Tante Elsa — Standesamt Ulm</div>
|
||||
<div class="d-meta">vor 2 Minuten hochgeladen</div>
|
||||
</div>
|
||||
<div class="d-chev">›</div>
|
||||
</div>
|
||||
<div class="d-row">
|
||||
<div class="d-icon pdf">PDF</div>
|
||||
<div class="d-main">
|
||||
<div class="d-title">Postkarte Bodensee 1958</div>
|
||||
<div class="d-meta">vor 2 Minuten hochgeladen · 2 Seiten</div>
|
||||
</div>
|
||||
<div class="d-chev">›</div>
|
||||
</div>
|
||||
<div class="d-row">
|
||||
<div class="d-icon tif">TIF</div>
|
||||
<div class="d-main">
|
||||
<div class="d-title">Meldebescheinigung Stadt Tübingen 1971</div>
|
||||
<div class="d-meta">vor 3 Minuten hochgeladen · 1 Seite</div>
|
||||
</div>
|
||||
<div class="d-chev">›</div>
|
||||
</div>
|
||||
<div class="d-footer">
|
||||
<span class="d-footer-link">Alle 12 anzeigen →</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="frame-caption">1440px · main content column (768–960px target) · 12 docs pending · row 2 shows keyboard focus state</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout navy">
|
||||
<div><strong>Row cap = 5.</strong> When pending docs exceed 5, the block shows the 5 most-recently-uploaded and a footer link to <code class="inline">/enrich</code>. This caps block height so the dashboard stays navigable, and reinforces <code class="inline">/enrich</code> as the canonical "long queue" page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SECTION 2 — ROW ANATOMY ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">02 — Row anatomy</div>
|
||||
<h2 class="sec-title">Icon · title · upload time · chevron</h2>
|
||||
<p class="sec-sub">The entire row is one <code class="inline"><a href="/enrich/{id}"></code>. No nested interactive elements — one tap, one destination. Touch target: <strong>72px minimum row height on mobile, 64px on desktop</strong>. Both exceed the 48px WCAG 2.2 AA floor for the senior audience.</p>
|
||||
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<h3>Left: file-type badge (20×20px)</h3>
|
||||
<p>Visual differentiation between PDF / JPG / PNG / TIF. Not decorative — gives seniors a scannable category cue without reading. Uses redundant color + text ("PDF", "JPG") so it passes WCAG 1.4.1.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Center: title + upload time</h3>
|
||||
<p>Title in <code class="inline">font-serif text-base</code> (16px mobile) / <code class="inline">text-lg</code> (18px desktop). Truncated with <code class="inline">text-ellipsis</code> on narrow viewports. Relative time ("vor 2 Min.") in <code class="inline">font-sans text-xs text-ink-2</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<h3>Right: chevron (decorative)</h3>
|
||||
<p>Navigation affordance. <code class="inline">aria-hidden="true"</code>. Becomes slightly more prominent on hover (<code class="inline">opacity-30 → 70</code>).</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>States: hover, focus-visible, active</h3>
|
||||
<p>Hover: <code class="inline">bg-brand-mint/10</code> wash. Focus-visible: <code class="inline">ring-2 ring-brand-navy ring-offset-2</code> <em>inside</em> the row (visible against row bg). Active: <code class="inline">bg-brand-mint/20</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout orange">
|
||||
<div><strong>DTO extension needed.</strong> Current <code class="inline">IncompleteDocumentDTO</code> only carries <code class="inline">id</code> and <code class="inline">title</code>. To render "vor 2 Min." and the file-type badge, add <code class="inline">uploadedAt: Instant</code> and <code class="inline">mimeType: String</code>. Small, cheap backend change. Without these fields the block still works (skip meta line, use a generic doc icon) but the senior-facing UX is meaningfully worse.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SECTION 3 — STATES ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">03 — States</div>
|
||||
<h2 class="sec-title">Empty · loading · error · after-upload</h2>
|
||||
|
||||
<div class="two-col" style="margin-bottom:24px;">
|
||||
<div>
|
||||
<h3>Empty (<code class="inline">length === 0</code>)</h3>
|
||||
<p>Render <code class="inline">null</code>. The block disappears entirely. Seniors don't need a "nothing to do" card — absence is clear.</p>
|
||||
<div class="empty-note" style="margin-top:10px;">(block not rendered)</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Loading</h3>
|
||||
<p>On initial dashboard SSR this data comes in with the page load — no spinner needed. If you later add client-side refresh, use a skeleton (3 rows of gray blocks at 72px height, <code class="inline">animate-pulse</code>, respects <code class="inline">prefers-reduced-motion</code>).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div>
|
||||
<h3>Error</h3>
|
||||
<p>If the <code class="inline">/api/documents/incomplete</code> call fails, render the block in a muted error state: eyebrow reads "Liste konnte nicht geladen werden", with a retry link. Do not suppress — the user needs to know their queue may be out of date.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>After-upload (immediately after DropZone fires)</h3>
|
||||
<p>Transient success banner above the block. Auto-dismiss after 8s, <strong>with</strong> a manual close X. Content: "12 Dokumente hochgeladen" + "Jetzt ergänzen →" CTA → scrolls/focuses the list block. Use <code class="inline">aria-live="polite"</code> so screen readers announce the count.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banner mockup -->
|
||||
<div style="margin-top:24px;">
|
||||
<div class="banner">
|
||||
<div class="banner-check">✓</div>
|
||||
<div class="banner-t"><strong>12 Dokumente hochgeladen.</strong> Jetzt ergänzen, damit sie durchsuchbar werden.</div>
|
||||
<span class="banner-cta">Ergänzen →</span>
|
||||
<span class="banner-x">×</span>
|
||||
</div>
|
||||
<span class="frame-caption">After-upload success banner · renders above the list block · auto-dismiss 8s · manual dismiss always available</span>
|
||||
</div>
|
||||
|
||||
<div class="callout green" style="margin-top:16px;">
|
||||
<div><strong>No auto-redirect.</strong> After a batch of 12 uploads, dumping the user into <code class="inline">/enrich/{firstId}</code> is disorienting. The banner gives them the moment without taking control away. The list block becomes the persistent landing spot for every return visit.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SECTION 4 — RESPONSIVE ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">04 — Responsive behavior</div>
|
||||
<h2 class="sec-title">Mobile-first, three breakpoints</h2>
|
||||
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th style="width:90px;">Breakpoint</th><th>Row height</th><th>Title size</th><th>Meta visibility</th><th>Padding</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>320px</td>
|
||||
<td class="px">72px</td>
|
||||
<td class="px">text-base (16px)</td>
|
||||
<td>Relative time only</td>
|
||||
<td class="px">px-3 py-3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>768px</td>
|
||||
<td class="px">72px</td>
|
||||
<td class="px">text-lg (18px)</td>
|
||||
<td>Time + page count</td>
|
||||
<td class="px">px-5 py-4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1440px</td>
|
||||
<td class="px">64px</td>
|
||||
<td class="px">text-lg (18px)</td>
|
||||
<td>Time + page count</td>
|
||||
<td class="px">px-6 py-4</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="callout navy" style="margin-top:16px;">
|
||||
<div><strong>Block width follows the main content column.</strong> On the dashboard grid this is <code class="inline">1fr</code> minus the DropZone sidebar — not the full viewport. At 1440px total width, the block lands around 900–960px wide.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SECTION 5 — IMPL REF ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">05 — Implementation reference</div>
|
||||
<h2 class="sec-title">Exact Tailwind classes & pixel values</h2>
|
||||
<p class="sec-sub">Rewire and extend <code class="inline">frontend/src/lib/components/DashboardNeedsMetadata.svelte</code>. The component already exists and is correctly typed — the changes are: new row anatomy, file-type icon, relative time, 5-item cap with footer, a11y landmark, and focus/hover states.</p>
|
||||
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th style="width:170px;">Region</th><th>Tailwind</th><th>Pixel values</th><th class="note">Notes</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Section wrapper</td>
|
||||
<td><code><section aria-labelledby="enrich-heading" class="mb-6"></code></td>
|
||||
<td class="px">margin-bottom 24px</td>
|
||||
<td class="note">Landmark for SR nav</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Eyebrow row</td>
|
||||
<td><code>flex items-center justify-between mb-3 px-1</code></td>
|
||||
<td class="px">mb 12px</td>
|
||||
<td class="note">Title + count badge</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Eyebrow heading</td>
|
||||
<td><code>text-xs font-bold uppercase tracking-widest text-gray-500</code></td>
|
||||
<td class="px">12px / 700</td>
|
||||
<td class="note">Not gray-400 — must pass AA on sand bg</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Count badge</td>
|
||||
<td><code>bg-brand-navy text-white text-xs font-bold px-2 py-0.5 rounded-full</code></td>
|
||||
<td class="px">12px / 700</td>
|
||||
<td class="note">Use <code>aria-live="polite"</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>List container</td>
|
||||
<td><code>bg-white border border-line rounded-sm shadow-sm overflow-hidden</code></td>
|
||||
<td class="px">border 1px, radius 2px</td>
|
||||
<td class="note">Matches card pattern</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>List element</td>
|
||||
<td><code><ol class="divide-y divide-line-2"></code></td>
|
||||
<td class="px">divider 1px</td>
|
||||
<td class="note">Ordered — upload time is the order</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Row link</td>
|
||||
<td><code>group flex items-center gap-3 px-3 py-3 md:px-5 md:py-4 min-h-[72px] lg:min-h-[64px] hover:bg-brand-mint/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset transition-colors</code></td>
|
||||
<td class="px">min-height 72px mobile / 64px desktop</td>
|
||||
<td class="note">Whole row is the <a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File-type badge</td>
|
||||
<td><code>shrink-0 w-5 h-5 md:w-6 md:h-6 rounded-sm flex items-center justify-center text-[10px] font-bold</code></td>
|
||||
<td class="px">20×20px mobile / 24×24 desktop</td>
|
||||
<td class="note">Color per mime: red/PDF, green/JPG|PNG, purple/TIF</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Title</td>
|
||||
<td><code>font-serif text-base md:text-lg text-ink group-hover:underline truncate</code></td>
|
||||
<td class="px">16px mobile / 18px desktop</td>
|
||||
<td class="note">Single-line truncate</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meta line</td>
|
||||
<td><code>font-sans text-xs text-ink-2 mt-0.5</code></td>
|
||||
<td class="px">12px</td>
|
||||
<td class="note">"vor N Min./Std." + optional "· N Seiten"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Chevron</td>
|
||||
<td><code>shrink-0 w-5 h-5 opacity-30 group-hover:opacity-70 transition-opacity</code></td>
|
||||
<td class="px">20×20px</td>
|
||||
<td class="note">Use <code>aria-hidden="true"</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Footer (when >5)</td>
|
||||
<td><code>border-t border-line bg-brand-sand/20 px-5 py-3 flex justify-end</code></td>
|
||||
<td class="px">py 12px</td>
|
||||
<td class="note">Only render when <code>length > 5</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Footer link</td>
|
||||
<td><code>font-sans text-xs font-bold uppercase tracking-widest text-brand-navy hover:underline</code></td>
|
||||
<td class="px">12px / 700</td>
|
||||
<td class="note">"Alle {n} anzeigen →"</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SECTION 6 — ACCESSIBILITY ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">06 — Accessibility (WCAG 2.2 AA)</div>
|
||||
<h2 class="sec-title">What axe-playwright must confirm</h2>
|
||||
|
||||
<div class="a11y-box">
|
||||
<h4>A11y contract</h4>
|
||||
<ul>
|
||||
<li><strong>Landmark:</strong> <code><section aria-labelledby="enrich-heading"></code> with an <code><h2 id="enrich-heading"></code>. SR users reach the block via landmark nav.</li>
|
||||
<li><strong>Ordered list:</strong> <code><ol></code>, not <code><div></code>. Upload time <em>is</em> the implicit order.</li>
|
||||
<li><strong>Count live region:</strong> count badge gets <code>aria-live="polite"</code> so SR announces "12 Dokumente benötigen Metadaten" when the number changes after upload.</li>
|
||||
<li><strong>Touch target:</strong> 72px row height on mobile ≫ WCAG 2.2 floor of 48px. The whole row is tappable, not just the title.</li>
|
||||
<li><strong>Focus visibility:</strong> <code>focus-visible:ring-2 ring-brand-navy ring-inset</code>. Outer ring would clip; inner ring is always visible against hover/active bg.</li>
|
||||
<li><strong>File-type cue:</strong> color <em>plus</em> text ("PDF", "JPG") — passes 1.4.1 (not color alone).</li>
|
||||
<li><strong>Contrast:</strong> title <code>text-ink</code> on white = 14.5:1 (AAA). Meta <code>text-ink-2</code> on white — must verify ≥4.5:1 in <em>both</em> light and dark mode.</li>
|
||||
<li><strong>Reduced motion:</strong> skeleton pulse and hover transitions respect <code>prefers-reduced-motion</code> (transition-duration 0.01ms).</li>
|
||||
<li><strong>Banner:</strong> <code>role="status" aria-live="polite"</code>; manual dismiss button labeled <code>aria-label="Benachrichtigung schließen"</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SECTION 7 — DARK MODE ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">07 — Dark mode</div>
|
||||
<h2 class="sec-title">Works via semantic tokens, verify contrast separately</h2>
|
||||
<p class="sec-sub">All colors come from existing tokens (<code class="inline">bg-white</code>, <code class="inline">text-ink</code>, <code class="inline">text-ink-2</code>, <code class="inline">border-line</code>, <code class="inline">bg-surface</code>). No hard-coded hex values. Dark mode inherits the remapped tokens.</p>
|
||||
|
||||
<div class="callout orange">
|
||||
<div><strong>Verify in dark mode:</strong> the file-type badges use mime-specific colors (red/green/purple) against white—in dark mode those same colors sit on a near-black background. Test each badge at its dark-mode contrast ratio; some may need lightness adjustment (e.g. <code class="inline">dark:text-red-300</code> instead of <code class="inline">text-red-600</code>). Run axe-playwright in both themes per project convention.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SECTION 8 — OUT OF SCOPE ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">08 — Explicitly out of scope</div>
|
||||
<h2 class="sec-title">What this spec does NOT cover</h2>
|
||||
<div class="callout navy">
|
||||
<div>
|
||||
<strong>Future work (separate specs):</strong>
|
||||
<ul style="margin-top:8px;padding-left:20px;">
|
||||
<li>List-block treatment for the <em>other</em> pipeline stages (segment, transcribe, review). If this pattern works, they likely graduate out of MissionControlStrip too.</li>
|
||||
<li>Bulk-enrich ("same sender for all", "apply these 3 tags to all") — a natural follow-up for batch-upload reality.</li>
|
||||
<li>Sort/filter on <code class="inline">/enrich</code> (by upload date, by file type, by uploader).</li>
|
||||
<li>Strip redesign once a 4th+ pipeline stage forces the issue.</li>
|
||||
<li>Thumbnails in rows (deferred — depends on backend preview generation).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== TEST PLAN ===== -->
|
||||
<div class="sec">
|
||||
<div class="sec-label">09 — Test plan</div>
|
||||
<h2 class="sec-title">What QA (Sara) will verify</h2>
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<thead><tr><th style="width:170px;">Test</th><th>What it verifies</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Vitest: component renders</td><td>With 0 docs, renders nothing. With 1–5, no footer. With 6+, footer link shows "Alle {n} anzeigen".</td></tr>
|
||||
<tr><td>Vitest: count badge</td><td>Badge reflects <code>incompleteDocs.length</code>, not capped list length.</td></tr>
|
||||
<tr><td>Playwright: axe, light mode</td><td>Dashboard passes a11y with block populated. Focus ring visible on keyboard nav through rows.</td></tr>
|
||||
<tr><td>Playwright: axe, dark mode</td><td>Same as above with <code>[data-theme="dark"]</code>. File-type badge contrast ratios verified.</td></tr>
|
||||
<tr><td>Playwright: 320/768/1440 screenshots</td><td>Block renders at all three breakpoints without overflow or truncation beyond title.</td></tr>
|
||||
<tr><td>Playwright: upload → banner → click CTA</td><td>After DropZone fires with 3+ files: banner appears, list block populates, CTA scrolls/focuses list. Banner auto-dismisses at 8s. Manual X dismisses immediately.</td></tr>
|
||||
<tr><td>Playwright: keyboard flow</td><td>Tab from DropZone reaches banner (if present), then list rows in order, then footer link. Enter on row navigates to <code>/enrich/{id}</code>.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<p style="font-family:var(--mono);font-size:10px;color:var(--muted);text-align:center;">
|
||||
Leonie Voss · UX Lead · Familienarchiv · 2026-04-20
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1145
docs/specs/person-dashboard-spec.html
Normal file
1145
docs/specs/person-dashboard-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,9 @@ import { test, expect } from '@playwright/test';
|
||||
const AUTHENTICATED_PAGES = [
|
||||
{ name: 'home', path: '/' },
|
||||
{ name: 'persons', path: '/persons' },
|
||||
{ name: 'admin', path: '/admin' }
|
||||
{ name: 'aktivitaeten', path: '/aktivitaeten' },
|
||||
{ name: 'admin', path: '/admin' },
|
||||
{ name: 'admin-system', path: '/admin/system' }
|
||||
];
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
|
||||
@@ -248,3 +248,28 @@ test.describe('Admin system tab — backfill file hashes', () => {
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── System tab — generate thumbnails ─────────────────────────────────────────
|
||||
|
||||
test.describe('Admin system tab — generate thumbnails', () => {
|
||||
test('admin triggers thumbnail generation and sees DONE within 30s', async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Navigate to System tab
|
||||
await page.getByRole('button', { name: /system/i }).click();
|
||||
|
||||
const btn = page
|
||||
.locator('[data-thumbnails-trigger]')
|
||||
.or(page.getByRole('button', { name: /thumbnails erzeugen/i }));
|
||||
await expect(btn.first()).toBeVisible();
|
||||
await btn.first().click();
|
||||
|
||||
// Status transitions RUNNING → DONE; poll message shows the final summary
|
||||
await expect(page.getByTestId('thumbnails-status-done')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-generate-thumbnails.png' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,12 +12,12 @@ const authFile = path.join(__dirname, '.auth/user.json');
|
||||
* E2E_PASSWORD (default: admin123)
|
||||
*/
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const username = process.env.E2E_USERNAME ?? 'admin';
|
||||
const username = process.env.E2E_USERNAME ?? 'admin@familyarchive.local';
|
||||
const password = process.env.E2E_PASSWORD ?? 'admin123';
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill(username);
|
||||
await page.getByLabel('Passwort').fill(password);
|
||||
await page.getByLabel(/e-mail/i).fill(username);
|
||||
await page.getByLabel(/passwort/i).fill(password);
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await page.waitForURL('/');
|
||||
|
||||
|
||||
61
frontend/e2e/back-button.spec.ts
Normal file
61
frontend/e2e/back-button.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// [data-hydrated] is set by +layout.svelte once SvelteKit's client-side hydration is complete.
|
||||
// Waiting on it ensures the component is interactive before we interact with it.
|
||||
|
||||
test.describe('BackButton — navigation', () => {
|
||||
test('returns to previous page via history when clicked', async ({ page }) => {
|
||||
// Navigate to persons list, then to a person detail
|
||||
await page.goto('/persons');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
|
||||
const personHref = await firstPersonLink.getAttribute('href');
|
||||
await firstPersonLink.click();
|
||||
await page.waitForURL(/\/persons\/.+/);
|
||||
|
||||
// Now navigate to the edit page from the detail page
|
||||
const editLink = page.locator('a[href$="/edit"]').first();
|
||||
await editLink.click();
|
||||
await page.waitForURL(/\/persons\/.+\/edit/);
|
||||
|
||||
// Click the BackButton — should return to person detail, not /persons list
|
||||
const backBtn = page.getByRole('button', { name: /zurück/i });
|
||||
await expect(backBtn).toBeVisible();
|
||||
await backBtn.click();
|
||||
|
||||
// Should return to the person detail URL (history.back()), not the static /persons
|
||||
await expect(page).toHaveURL(new RegExp(personHref!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('BackButton — accessibility', () => {
|
||||
test('touch target is at least 44px tall on /persons/new', async ({ page }) => {
|
||||
await page.goto('/persons/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const backBtn = page.getByRole('button', { name: /zurück/i });
|
||||
await expect(backBtn).toBeVisible();
|
||||
|
||||
const box = await backBtn.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
test('passes axe-core wcag2a/wcag2aa scan on /persons/new', async ({ page }) => {
|
||||
await page.goto('/persons/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => `[${v.impact}] ${v.id}: ${v.description}`)
|
||||
.join('\n');
|
||||
console.log(`\nAxe violations on /persons/new:\n${summary}`);
|
||||
}
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
65
frontend/e2e/briefwechsel-a11y.spec.ts
Normal file
65
frontend/e2e/briefwechsel-a11y.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedBilateralPair,
|
||||
cleanupBilateralPair,
|
||||
type BilateralPair
|
||||
} from './fixtures/bilateral-correspondence';
|
||||
|
||||
// Accessibility coverage for the briefwechsel thumbnail-row layout. Seeds
|
||||
// two persons + a bilateral document via the shared fixture so the page
|
||||
// reaches the results state (not the hero), then runs axe-core
|
||||
// (wcag2a + wcag2aa) across three viewports and two color schemes.
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 800 }
|
||||
] as const;
|
||||
|
||||
const THEMES = ['light', 'dark'] as const;
|
||||
|
||||
let pair: BilateralPair;
|
||||
|
||||
test.describe('Accessibility — /briefwechsel row layout', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
pair = await seedBilateralPair(request, 'A11y');
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupBilateralPair(request, pair);
|
||||
});
|
||||
|
||||
for (const vp of VIEWPORTS) {
|
||||
for (const theme of THEMES) {
|
||||
test(`${vp.name} / ${theme} has no wcag2a/wcag2aa violations`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.goto(
|
||||
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
|
||||
);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Assert we actually reached the row layout, not the hero — otherwise
|
||||
// the axe sweep silently scans the wrong DOM.
|
||||
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('main')
|
||||
.analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||
.join('\n');
|
||||
console.log(
|
||||
`\nAccessibility violations on briefwechsel (${vp.name}/${theme}):\n${summary}`
|
||||
);
|
||||
}
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
79
frontend/e2e/briefwechsel-rows.visual.spec.ts
Normal file
79
frontend/e2e/briefwechsel-rows.visual.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedBilateralPair,
|
||||
cleanupBilateralPair,
|
||||
type BilateralPair
|
||||
} from './fixtures/bilateral-correspondence';
|
||||
|
||||
// Visual + structural coverage for the new briefwechsel row layout.
|
||||
//
|
||||
// Seeds a bilateral correspondence pair via the shared fixture so the page
|
||||
// reaches the row state. The structural test asserts that a
|
||||
// ConversationThumbnail tile AND the DistributionBar render — regressions
|
||||
// that silently drop to the hero or break the {#each} wiring fail here.
|
||||
//
|
||||
// Snapshot assertions are gated on the VISUAL env flag because they need
|
||||
// pre-captured baselines (see `playwright test --update-snapshots` to
|
||||
// regenerate after intentional UI changes). CI can opt in via VISUAL=1.
|
||||
const VISUAL = process.env.VISUAL === '1';
|
||||
|
||||
let pair: BilateralPair;
|
||||
|
||||
test.describe('Briefwechsel — thumbnail-row layout', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
pair = await seedBilateralPair(request, 'Visual');
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupBilateralPair(request, pair);
|
||||
});
|
||||
|
||||
async function openBilateral(page: import('@playwright/test').Page) {
|
||||
await page.goto(
|
||||
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
|
||||
);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
// Parity with the a11y spec: fail loudly if we ever end up on the hero
|
||||
// instead of the row layout.
|
||||
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
|
||||
}
|
||||
|
||||
test('renders a ConversationThumbnail tile and the DistributionBar', async ({ page }) => {
|
||||
await openBilateral(page);
|
||||
|
||||
// Tile appears for the seeded document
|
||||
await expect(page.locator('[data-testid="conv-thumb-tile"]').first()).toBeVisible();
|
||||
|
||||
// DistributionBar is present (role=img with a descriptive aria-label)
|
||||
const bar = page.locator('[role="img"]');
|
||||
await expect(bar).toBeVisible();
|
||||
const label = (await bar.getAttribute('aria-label')) ?? '';
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Visual regression — one snapshot per (viewport × theme). Tolerance stays
|
||||
// generous (maxDiffPixels: 100) so antialiasing jitter doesn't flip them on
|
||||
// unrelated runs; genuine layout changes are still caught because the
|
||||
// thumbnail tile and distribution bar dominate the frame.
|
||||
test.describe('snapshots', () => {
|
||||
test.skip(!VISUAL, 'VISUAL=1 required to compare baselines');
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 800 }
|
||||
] as const) {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
test(`${viewport.name} / ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await openBilateral(page);
|
||||
await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, {
|
||||
maxDiffPixels: 100,
|
||||
fullPage: true
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
83
frontend/e2e/dashboard-enrichment-block.spec.ts
Normal file
83
frontend/e2e/dashboard-enrichment-block.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Dashboard enrichment-list-block (issue #296) — full upload → banner → CTA journey,
|
||||
* plus axe sweep in light and dark mode at 320 / 768 / 1440 viewports.
|
||||
*
|
||||
* The uploaded PDFs are deleted in afterEach so this spec does not pollute
|
||||
* the dev DB between runs.
|
||||
*/
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: '320', width: 320, height: 720 },
|
||||
{ name: '768', width: 768, height: 1024 },
|
||||
{ name: '1440', width: 1440, height: 900 }
|
||||
];
|
||||
|
||||
const psql = (sql: string) =>
|
||||
execSync(
|
||||
`docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"`
|
||||
);
|
||||
|
||||
test.afterEach(() => {
|
||||
// Remove any document whose filename matches the seeded sentinel — keeps the
|
||||
// DB clean for subsequent test runs.
|
||||
psql(`DELETE FROM documents WHERE original_filename IN ('minimal.pdf', 'minimal2.pdf');`);
|
||||
});
|
||||
|
||||
async function uploadViaDropZone(page: Page, files: string[]) {
|
||||
const inputLocator = page.locator('input[type="file"][accept*="pdf"]');
|
||||
await inputLocator.first().setInputFiles(files);
|
||||
}
|
||||
|
||||
test.describe('Dashboard enrichment block — upload → banner → CTA', () => {
|
||||
test('banner appears after upload and CTA navigates to /enrich', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const fixturePath = (name: string) => path.join(__dirname, 'fixtures', name);
|
||||
await uploadViaDropZone(page, [fixturePath('minimal.pdf'), fixturePath('minimal2.pdf')]);
|
||||
|
||||
const banner = page.getByRole('status').filter({ hasText: /hochgeladen/ });
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(/2 Dokumente/);
|
||||
|
||||
await banner.getByRole('link', { name: /ergänzen/i }).click();
|
||||
await expect(page).toHaveURL(/\/enrich(\/|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dashboard enrichment block — axe sweep', () => {
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`no wcag2a/wcag2aa violations at ${viewport.name}px (${scheme})`, async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: scheme,
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
storageState: path.join(__dirname, '.auth/user.json')
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
console.log(
|
||||
`Violations on dashboard @ ${viewport.name}px ${scheme}:\n` +
|
||||
results.violations.map((v) => `[${v.impact}] ${v.id}`).join('\n')
|
||||
);
|
||||
}
|
||||
expect(results.violations).toEqual([]);
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
38
frontend/e2e/document-topbar-back.spec.ts
Normal file
38
frontend/e2e/document-topbar-back.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('DocumentTopBar — back navigation', () => {
|
||||
test('BackButton returns to /documents after navigating from the documents list', async ({
|
||||
page
|
||||
}) => {
|
||||
// Navigate to home page first (mirrors the real user flow)
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Click the Dokumente nav link (SPA navigation — pushes to history)
|
||||
await page.click('a[href="/documents"]');
|
||||
await page.waitForURL('/documents');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Click first real document link (skip /documents/new and edit links)
|
||||
const docLink = page.locator('a[href^="/documents/"]:not([href="/documents/new"])').first();
|
||||
const count = await docLink.count();
|
||||
|
||||
if (count === 0) {
|
||||
test.skip(true, 'No documents in test database');
|
||||
}
|
||||
|
||||
const docHref = await docLink.getAttribute('href');
|
||||
await docLink.click();
|
||||
await page.waitForURL(new RegExp(docHref!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Find and click the BackButton in the DocumentTopBar
|
||||
const backBtn = page.locator('[data-topbar]').getByRole('button', { name: /zurück/i });
|
||||
await expect(backBtn).toBeVisible();
|
||||
await backBtn.click();
|
||||
|
||||
// Should be back at the documents list, not at /
|
||||
await page.waitForURL(/\/documents($|\?)/);
|
||||
expect(page.url()).toMatch(/\/documents($|\?)/);
|
||||
});
|
||||
});
|
||||
62
frontend/e2e/fixtures/bilateral-correspondence.ts
Normal file
62
frontend/e2e/fixtures/bilateral-correspondence.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Test fixture for the briefwechsel row layout.
|
||||
*
|
||||
* Creates two persons and one document with sender/receiver between them so
|
||||
* that `/briefwechsel?senderId=X&receiverId=Y` navigates straight to the row
|
||||
* state (not the hero). Each seed uses a `Date.now()`-suffixed last name so
|
||||
* parallel runs and reruns never collide.
|
||||
*
|
||||
* The backend does not expose a person-delete endpoint, so only the document
|
||||
* is cleaned up in {@link cleanupBilateralPair}. The two timestamped persons
|
||||
* remain in the DB — acceptable for the test environment, and the unique
|
||||
* suffix means they cannot conflict with later runs.
|
||||
*/
|
||||
|
||||
export interface BilateralPair {
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export async function seedBilateralPair(
|
||||
request: APIRequestContext,
|
||||
prefix: string
|
||||
): Promise<BilateralPair> {
|
||||
const timestamp = Date.now();
|
||||
|
||||
const senderRes = await request.post('/api/persons', {
|
||||
data: { firstName: prefix, lastName: `Sender-${timestamp}` }
|
||||
});
|
||||
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
|
||||
const senderId = (await senderRes.json()).id as string;
|
||||
|
||||
const receiverRes = await request.post('/api/persons', {
|
||||
data: { firstName: prefix, lastName: `Receiver-${timestamp}` }
|
||||
});
|
||||
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
|
||||
const receiverId = (await receiverRes.json()).id as string;
|
||||
|
||||
const docRes = await request.post('/api/documents', {
|
||||
multipart: {
|
||||
title: `${prefix} Brief`,
|
||||
documentDate: '1950-06-15',
|
||||
senderId,
|
||||
receiverIds: receiverId
|
||||
}
|
||||
});
|
||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||
const documentId = (await docRes.json()).id as string;
|
||||
|
||||
return { senderId, receiverId, documentId };
|
||||
}
|
||||
|
||||
export async function cleanupBilateralPair(
|
||||
request: APIRequestContext,
|
||||
pair: BilateralPair
|
||||
): Promise<void> {
|
||||
// Only the document is purged — the backend has no person-delete endpoint
|
||||
// and the timestamped last names make orphaned person rows safe to leave.
|
||||
await request.delete(`/api/documents/${pair.documentId}`);
|
||||
}
|
||||
30
frontend/e2e/help-popover.spec.ts
Normal file
30
frontend/e2e/help-popover.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||
|
||||
test.describe('Help chip — Read/Edit panel header', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
// Find and click the (?) help chip
|
||||
const helpBtn = page.locator('button[aria-expanded]');
|
||||
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
||||
await helpBtn.click();
|
||||
|
||||
// Popover should open
|
||||
await expect(page.locator('[role="tooltip"]')).toBeVisible();
|
||||
|
||||
// Press Esc
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[role="tooltip"]')).not.toBeVisible();
|
||||
|
||||
// Focus should have returned to the chip
|
||||
await expect(helpBtn).toBeFocused();
|
||||
});
|
||||
});
|
||||
10
frontend/e2e/helpers/upload-empty-document.ts
Normal file
10
frontend/e2e/helpers/upload-empty-document.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||
const res = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
|
||||
const doc = await res.json();
|
||||
return doc.id as string;
|
||||
}
|
||||
117
frontend/e2e/notification-deep-link.spec.ts
Normal file
117
frontend/e2e/notification-deep-link.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { AxeBuilder } from '@axe-core/playwright';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
|
||||
/**
|
||||
* E2E test for the notification deep-link scroll flow — issue #276.
|
||||
*
|
||||
* Seeds a document + transcription block + block comment via API, then
|
||||
* visits /documents/{id}?commentId=X&annotationId=Y and verifies:
|
||||
* - page enters transcribe mode
|
||||
* - the target comment is visible in the viewport
|
||||
* - focus lands on the comment article
|
||||
* - URL query params are stripped after handling
|
||||
*/
|
||||
|
||||
let docHref: string;
|
||||
let docId: string;
|
||||
let annotationId: string;
|
||||
let commentId: string;
|
||||
|
||||
test.describe('Notification deep-link scroll', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
docId = doc.id;
|
||||
docHref = `${baseURL}/documents/${docId}`;
|
||||
|
||||
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
documentDate: '1945-05-08',
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||
|
||||
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.1,
|
||||
text: 'Seeded line',
|
||||
label: null
|
||||
}
|
||||
});
|
||||
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||
const block = await blockRes.json();
|
||||
annotationId = block.annotationId;
|
||||
|
||||
const commentRes = await request.post(
|
||||
`/api/documents/${docId}/transcription-blocks/${block.id}/comments`,
|
||||
{
|
||||
data: { content: 'Target comment for deep-link test' }
|
||||
}
|
||||
);
|
||||
if (!commentRes.ok()) throw new Error(`Create comment failed: ${commentRes.status()}`);
|
||||
const comment = await commentRes.json();
|
||||
commentId = comment.id;
|
||||
});
|
||||
|
||||
async function openDeepLink(page: Page) {
|
||||
const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`;
|
||||
await page.goto(url);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 320, height: 700, name: 'mobile-320' },
|
||||
{ width: 1440, height: 900, name: 'desktop-1440' }
|
||||
]) {
|
||||
test(`deep-link scrolls comment into view at ${viewport.name}`, async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await openDeepLink(page);
|
||||
|
||||
// Transcribe mode was auto-entered — Fertig button is visible
|
||||
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The target comment article is in the DOM and visible
|
||||
const article = page.locator(`#comment-${commentId}`);
|
||||
await expect(article).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// URL query params are stripped after handling
|
||||
await expect.poll(() => page.url()).not.toContain('commentId=');
|
||||
await expect.poll(() => page.url()).not.toContain('annotationId=');
|
||||
|
||||
await page.screenshot({
|
||||
path: `test-results/e2e/notification-deep-link-${viewport.name}.png`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('axe accessibility check passes on document detail with deep-link', async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await openDeepLink(page);
|
||||
await expect(page.locator(`#comment-${commentId}`)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
expect(results.violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
68
frontend/e2e/richtlinien.spec.ts
Normal file
68
frontend/e2e/richtlinien.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
|
||||
test.describe('Richtlinien page — content', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/hilfe/transkription');
|
||||
});
|
||||
|
||||
test('renders h1 title, intro, five rules, four chips, closing card', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: /Transkriptions-Richtlinien/ })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Damit alle Briefe einheitlich/)).toBeVisible();
|
||||
await expect(page.getByText('Nicht lesbare Wörter')).toBeVisible();
|
||||
await expect(page.getByText('Durchgestrichene Wörter')).toBeVisible();
|
||||
await expect(page.getByText(/Das lange s/)).toBeVisible();
|
||||
await expect(page.getByText('Unsichere Namen')).toBeVisible();
|
||||
await expect(page.getByText(/Dialekt/)).toBeVisible();
|
||||
await expect(page.getByText('Abkürzungen')).toBeVisible();
|
||||
await expect(page.getByText('Datumsformate')).toBeVisible();
|
||||
await expect(page.getByText(/Fehlt eine Regel/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Wikipedia link opens in new tab with annotation', async ({ page }) => {
|
||||
const wikiLink = page.getByRole('link', { name: /Wikipedia/ });
|
||||
await expect(wikiLink).toHaveAttribute('target', '_blank');
|
||||
await expect(wikiLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
await expect(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
await expect(wikiLink).toContainText(/öffnet in neuem Tab/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Richtlinien page — accessibility', () => {
|
||||
for (const viewport of [320, 768, 1440]) {
|
||||
test(`axe: light theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport, height: 800 });
|
||||
await page.goto('/hilfe/transkription');
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test(`axe: dark theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport, height: 800 });
|
||||
await page.goto('/hilfe/transkription');
|
||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Richtlinien page — print media', () => {
|
||||
test('print snapshot hides nav, annotation chip, and new-tab spans', async ({ page }) => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
await page.goto('/hilfe/transkription');
|
||||
|
||||
const nav = page.locator('.app-nav');
|
||||
if ((await nav.count()) > 0) {
|
||||
await expect(nav).toBeHidden();
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
65
frontend/e2e/transcribe-coach.spec.ts
Normal file
65
frontend/e2e/transcribe-coach.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
|
||||
test.describe('Transcribe coach — empty state', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
await expect(page.getByText(/Kurrent-Erkenner lernt noch/)).toBeVisible();
|
||||
await expect(page.getByText(/Rahmen ziehen/)).toBeVisible();
|
||||
await expect(page.getByText(/Text eingeben/)).toBeVisible();
|
||||
await expect(page.getByText(/Speichert automatisch/)).toBeVisible();
|
||||
await expect(page.getByRole('img', { name: /Rahmen ziehen|Animation/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
// Toggle dark theme
|
||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,8 @@
|
||||
"docs_empty_heading": "Keine Dokumente gefunden",
|
||||
"docs_empty_text": "Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.",
|
||||
"docs_empty_btn_clear": "Alle Filter löschen",
|
||||
"docs_group_unknown_sender": "Unbekannter Absender",
|
||||
"docs_group_unknown_receiver": "Unbekannter Empfänger",
|
||||
"docs_list_from": "Von",
|
||||
"docs_list_to": "An",
|
||||
"docs_list_content": "Inhalt",
|
||||
@@ -163,6 +165,10 @@
|
||||
"conv_hero_divider": "oder",
|
||||
"conv_empty_recent_label": "Zuletzt geöffnet",
|
||||
"conv_no_party": "—",
|
||||
"dist_bar_segment": "{count} von {name}",
|
||||
"dist_bar_aria": "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}",
|
||||
"row_direction_sent": "Gesendet",
|
||||
"row_direction_received": "Empfangen",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Benutzer",
|
||||
"admin_tab_groups": "Gruppen",
|
||||
@@ -336,6 +342,13 @@
|
||||
"admin_system_import_status_running": "Import läuft…",
|
||||
"admin_system_import_status_done": "Import abgeschlossen – {count} Dokumente verarbeitet.",
|
||||
"admin_system_import_status_failed": "Fehler: {message}",
|
||||
"admin_system_thumbnails_heading": "Thumbnails erzeugen",
|
||||
"admin_system_thumbnails_description": "Erzeugt Vorschaubilder für Dokumente ohne Thumbnail (z. B. nach dem Massenimport).",
|
||||
"admin_system_thumbnails_btn_start": "Thumbnails erzeugen",
|
||||
"admin_system_thumbnails_btn_retry": "Erneut starten",
|
||||
"admin_system_thumbnails_status_running": "Thumbnail-Generierung läuft…",
|
||||
"admin_system_thumbnails_status_done": "Fertig – {processed} erzeugt, {skipped} übersprungen, {failed} fehlgeschlagen.",
|
||||
"admin_system_thumbnails_status_failed": "Fehler: {message}",
|
||||
"comp_expandable_show_more": "Mehr anzeigen",
|
||||
"comp_expandable_show_less": "Weniger anzeigen",
|
||||
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||
@@ -413,6 +426,11 @@
|
||||
"dashboard_notification_replied": "hat geantwortet",
|
||||
"dashboard_needs_metadata_heading": "Metadaten fehlen",
|
||||
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
||||
"dashboard_needs_metadata_show_all_count": "Alle {count} anzeigen →",
|
||||
"upload_banner_singular": "1 Dokument hochgeladen.",
|
||||
"upload_banner_plural": "{count} Dokumente hochgeladen.",
|
||||
"upload_banner_cta": "Jetzt ergänzen →",
|
||||
"upload_banner_close": "Benachrichtigung schließen",
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_stats_documents": "Dokumente",
|
||||
"dashboard_stats_persons": "Personen",
|
||||
@@ -481,7 +499,7 @@
|
||||
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
|
||||
"transcription_block_history_btn": "Verlauf",
|
||||
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
|
||||
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
|
||||
"transcription_next_block_cta": "Einen Rahmen ziehen, um Block {number} anzulegen",
|
||||
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
||||
@@ -749,5 +767,112 @@
|
||||
"audit_action_comment_added": "hat kommentiert:",
|
||||
"audit_action_mention_created": "hat dich erwähnt in",
|
||||
|
||||
"dropzone_release": "Loslassen zum Hochladen"
|
||||
"dropzone_release": "Loslassen zum Hochladen",
|
||||
|
||||
"chronik_page_title": "Aktivitäten",
|
||||
"chronik_for_you_caption": "Für dich",
|
||||
"chronik_for_you_count": "{count} neu",
|
||||
"chronik_mark_read_aria": "Als gelesen markieren",
|
||||
"chronik_mark_all_read": "Alle gelesen",
|
||||
"chronik_inbox_zero_title": "Keine neuen Erwähnungen",
|
||||
"chronik_inbox_zero_link": "Ältere Erwähnungen ansehen →",
|
||||
"chronik_filter_label": "Aktivitäten filtern",
|
||||
"chronik_filter_all": "Alle",
|
||||
"chronik_filter_for_you": "Für dich",
|
||||
"chronik_filter_uploaded": "Hochgeladen",
|
||||
"chronik_filter_transcription": "Transkription",
|
||||
"chronik_filter_comments": "Kommentare",
|
||||
"chronik_day_today": "Heute",
|
||||
"chronik_day_yesterday": "Gestern",
|
||||
"chronik_day_this_week": "Diese Woche",
|
||||
"chronik_day_older": "Älter",
|
||||
"chronik_singleton_text_saved": "{actor} transkribierte einen Block in {doc}",
|
||||
"chronik_rollup_text_saved": "{actor} transkribierte {doc}",
|
||||
"chronik_singleton_uploaded": "{actor} lud {doc} hoch",
|
||||
"chronik_rollup_uploaded": "{actor} lud Dokumente hoch",
|
||||
"chronik_singleton_reviewed": "{actor} überprüfte einen Block in {doc}",
|
||||
"chronik_rollup_reviewed": "{actor} überprüfte {doc}",
|
||||
"chronik_singleton_annotated": "{actor} annotierte {doc}",
|
||||
"chronik_rollup_annotated": "{actor} annotierte {doc}",
|
||||
"chronik_comment_added": "{actor} kommentierte {doc}",
|
||||
"chronik_mention_created": "{actor} erwähnte dich in {doc}",
|
||||
"chronik_reply_received": "{actor} antwortete dir in {doc}",
|
||||
"chronik_empty_first_run_title": "Noch nichts geschehen",
|
||||
"chronik_empty_first_run_body": "Sobald jemand aus der Familie Dokumente hochlädt oder transkribiert, erscheint hier die Aktivität.",
|
||||
"chronik_empty_filter_title": "Nichts in dieser Ansicht",
|
||||
"chronik_empty_filter_body": "In diesem Filter gibt es keine Einträge.",
|
||||
"chronik_error_title": "Die Aktivitäten konnten nicht geladen werden.",
|
||||
"chronik_error_retry": "Erneut versuchen",
|
||||
"chronik_load_more": "Mehr laden",
|
||||
"chronik_loading": "Lädt …",
|
||||
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
||||
"chronik_view_all": "Alle Aktivitäten →",
|
||||
"pagination_prev": "Zurück",
|
||||
"pagination_next": "Weiter",
|
||||
"pagination_page_of": "Seite {page} von {total}",
|
||||
"pagination_nav_label": "Seitennavigation",
|
||||
|
||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||
|
||||
"transcribe_coach_title": "Erste Transkription?",
|
||||
"transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:",
|
||||
"transcribe_coach_step_1_title": "Rahmen ziehen.",
|
||||
"transcribe_coach_step_1_body": "Klicken und ziehen Sie mit der Maus einen Rahmen um den Text, den Sie transkribieren möchten.",
|
||||
"transcribe_coach_step_2_title": "Text eingeben.",
|
||||
"transcribe_coach_step_2_body": "Geben Sie den Text, den Sie im Rahmen sehen, in das neue Textfeld ein.",
|
||||
"transcribe_coach_step_3_title": "Speichert automatisch.",
|
||||
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
|
||||
|
||||
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_rules_label": "Regeln für die Transkription",
|
||||
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
|
||||
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Durchgestrichene Wörter",
|
||||
"richtlinien_rule_durchgestrichen_body": "Auch durchgestrichener Text gehört zum Brief. Schreiben Sie ihn in eckigen Klammern mit Präfix durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "Das lange s (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "Das ſ ist nur eine alte Schriftform des Buchstabens s — kein eigener Laut. Schreiben Sie immer ein normales s.",
|
||||
"richtlinien_rule_name_title": "Unsichere Namen",
|
||||
"richtlinien_rule_name_body": "Wenn Sie einen Namen zu erkennen meinen, aber nicht sicher sind, ergänzen Sie ein Fragezeichen in eckigen Klammern.",
|
||||
"richtlinien_rule_dialekt_title": "Dialekt, Fremdwörter, fremde Zitate",
|
||||
"richtlinien_rule_dialekt_body": "Plattdeutsch, Französisch, lateinische Phrasen — wörtlich übernehmen, genau wie sie geschrieben stehen.",
|
||||
"richtlinien_beispiel_label": "Beispiel",
|
||||
"richtlinien_klaerung_label": "Noch in Klärung",
|
||||
"richtlinien_klaerung_intro": "Diese Fragen klären wir noch — stoßen Sie beim Transkribieren darauf, treffen Sie eine plausible Wahl und notieren Sie es in den Kommentaren:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abkürzungen",
|
||||
"richtlinien_klaer_datumsformate": "Datumsformate",
|
||||
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
||||
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
||||
"richtlinien_closing_title": "Fehlt eine Regel?",
|
||||
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.",
|
||||
"error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.",
|
||||
"bulk_drop_hint": "Eine oder mehrere Dateien ablegen",
|
||||
"bulk_drop_sub": "PDF · bis zu 50 MB pro Datei",
|
||||
"bulk_count_pill": "{count} werden erstellt",
|
||||
"bulk_save_cta_one": "Speichern →",
|
||||
"bulk_save_cta": "{count} speichern →",
|
||||
"bulk_discard_all": "Alle verwerfen",
|
||||
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"bulk_add_more": "Weitere hinzufügen",
|
||||
"bulk_scope_per_file_label": "Nur diese Datei",
|
||||
"bulk_scope_shared_label": "Gilt für alle {count}",
|
||||
"bulk_title_suggested_hint": "Vorschlag aus Dateiname — zum Bearbeiten anklicken",
|
||||
"bulk_switcher_prev": "Vorherige Datei",
|
||||
"bulk_switcher_next": "Nächste Datei",
|
||||
"bulk_file_error_chip_label": "Fehler beim Hochladen",
|
||||
"bulk_upload_progress": "{done} von {total} hochgeladen",
|
||||
"bulk_partial_success": "{created} erstellt, {failed} fehlgeschlagen",
|
||||
"bulk_all_failed": "Alle Uploads fehlgeschlagen",
|
||||
"bulk_drop_desc": "Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt — alle anderen Felder gelten für alle gemeinsam.",
|
||||
"bulk_select_files": "Dateien auswählen",
|
||||
"bulk_drop_zone_label": "Dateien ablegen",
|
||||
"bulk_remove_file": "Entfernen",
|
||||
"bulk_title_single": "Neues Dokument",
|
||||
"bulk_title_multi": "Neue Dokumente"
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
"docs_empty_heading": "No documents found",
|
||||
"docs_empty_text": "Try adjusting the filters or changing the search term.",
|
||||
"docs_empty_btn_clear": "Clear all filters",
|
||||
"docs_group_unknown_sender": "Unknown sender",
|
||||
"docs_group_unknown_receiver": "Unknown recipient",
|
||||
"docs_list_from": "From",
|
||||
"docs_list_to": "To",
|
||||
"docs_list_content": "Content",
|
||||
@@ -163,6 +165,10 @@
|
||||
"conv_hero_divider": "or",
|
||||
"conv_empty_recent_label": "Recently opened",
|
||||
"conv_no_party": "—",
|
||||
"dist_bar_segment": "{count} from {name}",
|
||||
"dist_bar_aria": "Letter distribution in this period: {outCount} from {senderName}, {inCount} from {receiverName}",
|
||||
"row_direction_sent": "Sent",
|
||||
"row_direction_received": "Received",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Users",
|
||||
"admin_tab_groups": "Groups",
|
||||
@@ -336,6 +342,13 @@
|
||||
"admin_system_import_status_running": "Import running…",
|
||||
"admin_system_import_status_done": "Import complete – {count} documents processed.",
|
||||
"admin_system_import_status_failed": "Error: {message}",
|
||||
"admin_system_thumbnails_heading": "Generate thumbnails",
|
||||
"admin_system_thumbnails_description": "Generates preview images for documents without a thumbnail (e.g. after the mass import).",
|
||||
"admin_system_thumbnails_btn_start": "Generate thumbnails",
|
||||
"admin_system_thumbnails_btn_retry": "Run again",
|
||||
"admin_system_thumbnails_status_running": "Thumbnail generation running…",
|
||||
"admin_system_thumbnails_status_done": "Done — {processed} generated, {skipped} skipped, {failed} failed.",
|
||||
"admin_system_thumbnails_status_failed": "Error: {message}",
|
||||
"comp_expandable_show_more": "Show more",
|
||||
"comp_expandable_show_less": "Show less",
|
||||
"error_comment_not_found": "The comment could not be found.",
|
||||
@@ -413,6 +426,11 @@
|
||||
"dashboard_notification_replied": "replied",
|
||||
"dashboard_needs_metadata_heading": "Missing Metadata",
|
||||
"dashboard_needs_metadata_show_all": "Show all",
|
||||
"dashboard_needs_metadata_show_all_count": "Show all {count} →",
|
||||
"upload_banner_singular": "1 document uploaded.",
|
||||
"upload_banner_plural": "{count} documents uploaded.",
|
||||
"upload_banner_cta": "Enrich now →",
|
||||
"upload_banner_close": "Dismiss notification",
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_stats_documents": "Documents",
|
||||
"dashboard_stats_persons": "Persons",
|
||||
@@ -481,7 +499,7 @@
|
||||
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
|
||||
"transcription_block_history_btn": "History",
|
||||
"transcription_empty_cta": "Mark a region on the scan to start transcribing",
|
||||
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}",
|
||||
"transcription_next_block_cta": "Draw a frame on the scan to create block {number}",
|
||||
"transcription_draw_tooltip": "Click and drag to mark a text region",
|
||||
"transcription_quote_stale": "Quote from an older version",
|
||||
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
||||
@@ -749,5 +767,112 @@
|
||||
"audit_action_comment_added": "commented:",
|
||||
"audit_action_mention_created": "mentioned you in",
|
||||
|
||||
"dropzone_release": "Release to upload"
|
||||
"dropzone_release": "Release to upload",
|
||||
|
||||
"chronik_page_title": "Activity",
|
||||
"chronik_for_you_caption": "For you",
|
||||
"chronik_for_you_count": "{count} new",
|
||||
"chronik_mark_read_aria": "Mark as read",
|
||||
"chronik_mark_all_read": "Mark all read",
|
||||
"chronik_inbox_zero_title": "No new mentions",
|
||||
"chronik_inbox_zero_link": "See older mentions →",
|
||||
"chronik_filter_label": "Filter activity",
|
||||
"chronik_filter_all": "All",
|
||||
"chronik_filter_for_you": "For you",
|
||||
"chronik_filter_uploaded": "Uploaded",
|
||||
"chronik_filter_transcription": "Transcription",
|
||||
"chronik_filter_comments": "Comments",
|
||||
"chronik_day_today": "Today",
|
||||
"chronik_day_yesterday": "Yesterday",
|
||||
"chronik_day_this_week": "This week",
|
||||
"chronik_day_older": "Older",
|
||||
"chronik_singleton_text_saved": "{actor} transcribed a block in {doc}",
|
||||
"chronik_rollup_text_saved": "{actor} transcribed {doc}",
|
||||
"chronik_singleton_uploaded": "{actor} uploaded {doc}",
|
||||
"chronik_rollup_uploaded": "{actor} uploaded documents",
|
||||
"chronik_singleton_reviewed": "{actor} reviewed a block in {doc}",
|
||||
"chronik_rollup_reviewed": "{actor} reviewed {doc}",
|
||||
"chronik_singleton_annotated": "{actor} annotated {doc}",
|
||||
"chronik_rollup_annotated": "{actor} annotated {doc}",
|
||||
"chronik_comment_added": "{actor} commented on {doc}",
|
||||
"chronik_mention_created": "{actor} mentioned you in {doc}",
|
||||
"chronik_reply_received": "{actor} replied to you in {doc}",
|
||||
"chronik_empty_first_run_title": "Nothing has happened yet",
|
||||
"chronik_empty_first_run_body": "As soon as someone in the family uploads or transcribes a document, the activity will show up here.",
|
||||
"chronik_empty_filter_title": "Nothing in this view",
|
||||
"chronik_empty_filter_body": "There are no entries for this filter.",
|
||||
"chronik_error_title": "Activity could not be loaded.",
|
||||
"chronik_error_retry": "Try again",
|
||||
"chronik_load_more": "Load more",
|
||||
"chronik_loading": "Loading …",
|
||||
"chronik_load_more_announcement": "{count} more entries loaded",
|
||||
"chronik_view_all": "All activity →",
|
||||
"pagination_prev": "Previous",
|
||||
"pagination_next": "Next",
|
||||
"pagination_page_of": "Page {page} of {total}",
|
||||
"pagination_nav_label": "Pagination",
|
||||
|
||||
"common_opens_new_tab": "(opens in new tab)",
|
||||
|
||||
"transcribe_coach_title": "First transcription?",
|
||||
"transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:",
|
||||
"transcribe_coach_step_1_title": "Draw a frame.",
|
||||
"transcribe_coach_step_1_body": "Click and drag a frame around the text you want to transcribe.",
|
||||
"transcribe_coach_step_2_title": "Enter the text.",
|
||||
"transcribe_coach_step_2_body": "Type the text you see inside the frame into the new text field.",
|
||||
"transcribe_coach_step_3_title": "Saves automatically.",
|
||||
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
|
||||
|
||||
"transcription_mode_help_label": "Read and edit mode",
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_rules_label": "Transcription rules",
|
||||
"richtlinien_rule_unleserlich_title": "Illegible words",
|
||||
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Struck-through words",
|
||||
"richtlinien_rule_durchgestrichen_body": "Struck-through text still belongs to the letter. Write it in square brackets with prefix durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "The long s (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "The ſ is just an old written form of the letter s — not a separate sound. Always write a normal s.",
|
||||
"richtlinien_rule_name_title": "Uncertain names",
|
||||
"richtlinien_rule_name_body": "If you think you can read a name but aren't sure, add a question mark in square brackets.",
|
||||
"richtlinien_rule_dialekt_title": "Dialect, foreign words, foreign quotes",
|
||||
"richtlinien_rule_dialekt_body": "Low German, French, Latin phrases — copy them verbatim, exactly as written.",
|
||||
"richtlinien_beispiel_label": "Example",
|
||||
"richtlinien_klaerung_label": "Still to be decided",
|
||||
"richtlinien_klaerung_intro": "These questions are still open — if you hit one while transcribing, make a plausible choice and note it in the comments:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abbreviations",
|
||||
"richtlinien_klaer_datumsformate": "Date formats",
|
||||
"richtlinien_klaer_umbrueche": "Original line breaks",
|
||||
"richtlinien_klaer_caps": "Old capitalisation",
|
||||
"richtlinien_closing_title": "Missing a rule?",
|
||||
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.",
|
||||
"error_batch_too_large": "Too many files at once — please upload in smaller batches.",
|
||||
"bulk_drop_hint": "Drop one or more files here",
|
||||
"bulk_drop_sub": "PDF · up to 50 MB per file",
|
||||
"bulk_count_pill": "{count} will be created",
|
||||
"bulk_save_cta_one": "Save →",
|
||||
"bulk_save_cta": "Save {count} →",
|
||||
"bulk_discard_all": "Discard all",
|
||||
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
|
||||
"bulk_add_more": "Add more",
|
||||
"bulk_scope_per_file_label": "This file only",
|
||||
"bulk_scope_shared_label": "Applies to all {count}",
|
||||
"bulk_title_suggested_hint": "Suggested from filename — click to edit",
|
||||
"bulk_switcher_prev": "Previous file",
|
||||
"bulk_switcher_next": "Next file",
|
||||
"bulk_file_error_chip_label": "Upload failed",
|
||||
"bulk_upload_progress": "{done} of {total} uploaded",
|
||||
"bulk_partial_success": "{created} created, {failed} failed",
|
||||
"bulk_all_failed": "All uploads failed",
|
||||
"bulk_drop_desc": "A separate document is created for each file. The title is pre-filled from the filename — all other fields apply to all documents.",
|
||||
"bulk_select_files": "Select files",
|
||||
"bulk_drop_zone_label": "Drop files here",
|
||||
"bulk_remove_file": "Remove",
|
||||
"bulk_title_single": "New Document",
|
||||
"bulk_title_multi": "New Documents"
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
"docs_empty_heading": "No se encontraron documentos",
|
||||
"docs_empty_text": "Intente ajustar los filtros o cambiar el término de búsqueda.",
|
||||
"docs_empty_btn_clear": "Borrar todos los filtros",
|
||||
"docs_group_unknown_sender": "Remitente desconocido",
|
||||
"docs_group_unknown_receiver": "Destinatario desconocido",
|
||||
"docs_list_from": "De",
|
||||
"docs_list_to": "Para",
|
||||
"docs_list_content": "Contenido",
|
||||
@@ -163,6 +165,10 @@
|
||||
"conv_hero_divider": "o",
|
||||
"conv_empty_recent_label": "Recientemente abiertos",
|
||||
"conv_no_party": "—",
|
||||
"dist_bar_segment": "{count} de {name}",
|
||||
"dist_bar_aria": "Distribución de cartas en este período: {outCount} de {senderName}, {inCount} de {receiverName}",
|
||||
"row_direction_sent": "Enviada",
|
||||
"row_direction_received": "Recibida",
|
||||
"admin_heading": "Panel de administración",
|
||||
"admin_tab_users": "Usuarios",
|
||||
"admin_tab_groups": "Grupos",
|
||||
@@ -336,6 +342,13 @@
|
||||
"admin_system_import_status_running": "Importación en curso…",
|
||||
"admin_system_import_status_done": "Importación completada – {count} documentos procesados.",
|
||||
"admin_system_import_status_failed": "Error: {message}",
|
||||
"admin_system_thumbnails_heading": "Generar miniaturas",
|
||||
"admin_system_thumbnails_description": "Genera imágenes de vista previa para documentos sin miniatura (p. ej. tras la importación masiva).",
|
||||
"admin_system_thumbnails_btn_start": "Generar miniaturas",
|
||||
"admin_system_thumbnails_btn_retry": "Reiniciar",
|
||||
"admin_system_thumbnails_status_running": "Generación de miniaturas en curso…",
|
||||
"admin_system_thumbnails_status_done": "Listo — {processed} generadas, {skipped} omitidas, {failed} fallidas.",
|
||||
"admin_system_thumbnails_status_failed": "Error: {message}",
|
||||
"comp_expandable_show_more": "Mostrar más",
|
||||
"comp_expandable_show_less": "Mostrar menos",
|
||||
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
||||
@@ -413,6 +426,11 @@
|
||||
"dashboard_notification_replied": "respondió",
|
||||
"dashboard_needs_metadata_heading": "Metadatos incompletos",
|
||||
"dashboard_needs_metadata_show_all": "Ver todos",
|
||||
"dashboard_needs_metadata_show_all_count": "Ver los {count} →",
|
||||
"upload_banner_singular": "1 documento subido.",
|
||||
"upload_banner_plural": "{count} documentos subidos.",
|
||||
"upload_banner_cta": "Completar ahora →",
|
||||
"upload_banner_close": "Cerrar notificación",
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_stats_documents": "Documentos",
|
||||
"dashboard_stats_persons": "Personas",
|
||||
@@ -481,7 +499,7 @@
|
||||
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
|
||||
"transcription_block_history_btn": "Historial",
|
||||
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
|
||||
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
|
||||
"transcription_next_block_cta": "Dibuje un marco en el escáner para crear el bloque {number}",
|
||||
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
||||
"transcription_quote_stale": "Cita de una versión anterior",
|
||||
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
||||
@@ -749,5 +767,112 @@
|
||||
"audit_action_comment_added": "comentó:",
|
||||
"audit_action_mention_created": "te mencionó en",
|
||||
|
||||
"dropzone_release": "Suelta para subir"
|
||||
"dropzone_release": "Suelta para subir",
|
||||
|
||||
"chronik_page_title": "Actividades",
|
||||
"chronik_for_you_caption": "Para ti",
|
||||
"chronik_for_you_count": "{count} nuevas",
|
||||
"chronik_mark_read_aria": "Marcar como leído",
|
||||
"chronik_mark_all_read": "Marcar todas leídas",
|
||||
"chronik_inbox_zero_title": "Sin nuevas menciones",
|
||||
"chronik_inbox_zero_link": "Ver menciones anteriores →",
|
||||
"chronik_filter_label": "Filtrar actividad",
|
||||
"chronik_filter_all": "Todas",
|
||||
"chronik_filter_for_you": "Para ti",
|
||||
"chronik_filter_uploaded": "Subidos",
|
||||
"chronik_filter_transcription": "Transcripción",
|
||||
"chronik_filter_comments": "Comentarios",
|
||||
"chronik_day_today": "Hoy",
|
||||
"chronik_day_yesterday": "Ayer",
|
||||
"chronik_day_this_week": "Esta semana",
|
||||
"chronik_day_older": "Anterior",
|
||||
"chronik_singleton_text_saved": "{actor} transcribió un bloque en {doc}",
|
||||
"chronik_rollup_text_saved": "{actor} transcribió {doc}",
|
||||
"chronik_singleton_uploaded": "{actor} subió {doc}",
|
||||
"chronik_rollup_uploaded": "{actor} subió documentos",
|
||||
"chronik_singleton_reviewed": "{actor} revisó un bloque en {doc}",
|
||||
"chronik_rollup_reviewed": "{actor} revisó {doc}",
|
||||
"chronik_singleton_annotated": "{actor} anotó {doc}",
|
||||
"chronik_rollup_annotated": "{actor} anotó {doc}",
|
||||
"chronik_comment_added": "{actor} comentó en {doc}",
|
||||
"chronik_mention_created": "{actor} te mencionó en {doc}",
|
||||
"chronik_reply_received": "{actor} te respondió en {doc}",
|
||||
"chronik_empty_first_run_title": "Aún no ha pasado nada",
|
||||
"chronik_empty_first_run_body": "En cuanto alguien de la familia suba o transcriba un documento, la actividad aparecerá aquí.",
|
||||
"chronik_empty_filter_title": "Nada en esta vista",
|
||||
"chronik_empty_filter_body": "No hay entradas para este filtro.",
|
||||
"chronik_error_title": "No se pudieron cargar las actividades.",
|
||||
"chronik_error_retry": "Reintentar",
|
||||
"chronik_load_more": "Cargar más",
|
||||
"chronik_loading": "Cargando …",
|
||||
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
||||
"chronik_view_all": "Todas las actividades →",
|
||||
"pagination_prev": "Anterior",
|
||||
"pagination_next": "Siguiente",
|
||||
"pagination_page_of": "Página {page} de {total}",
|
||||
"pagination_nav_label": "Paginación",
|
||||
|
||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||
|
||||
"transcribe_coach_title": "¿Primera transcripción?",
|
||||
"transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:",
|
||||
"transcribe_coach_step_1_title": "Dibujar un marco.",
|
||||
"transcribe_coach_step_1_body": "Haga clic y arrastre un marco alrededor del texto que desea transcribir.",
|
||||
"transcribe_coach_step_2_title": "Ingresar el texto.",
|
||||
"transcribe_coach_step_2_body": "Escriba el texto que ve dentro del marco en el nuevo campo de texto.",
|
||||
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
|
||||
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
|
||||
|
||||
"transcription_mode_help_label": "Modo lectura y edición",
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_rules_label": "Reglas de transcripción",
|
||||
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
|
||||
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Palabras tachadas",
|
||||
"richtlinien_rule_durchgestrichen_body": "El texto tachado también pertenece a la carta. Escríbelo entre corchetes con el prefijo durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "La s larga (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "La ſ es solo una forma antigua de la letra s. Escribe siempre una s normal.",
|
||||
"richtlinien_rule_name_title": "Nombres inciertos",
|
||||
"richtlinien_rule_name_body": "Si crees reconocer un nombre pero no estás seguro, añade un signo de interrogación entre corchetes.",
|
||||
"richtlinien_rule_dialekt_title": "Dialecto, palabras extranjeras, citas",
|
||||
"richtlinien_rule_dialekt_body": "Bajo alemán, francés, frases latinas — cópialas tal cual están escritas.",
|
||||
"richtlinien_beispiel_label": "Ejemplo",
|
||||
"richtlinien_klaerung_label": "Aún por decidir",
|
||||
"richtlinien_klaerung_intro": "Estas preguntas aún están abiertas — si encuentras alguna mientras transcribes, elige algo razonable y nótalo en los comentarios:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abreviaturas",
|
||||
"richtlinien_klaer_datumsformate": "Formatos de fecha",
|
||||
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
||||
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
||||
"richtlinien_closing_title": "¿Falta una regla?",
|
||||
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.",
|
||||
"error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.",
|
||||
"bulk_drop_hint": "Suelta uno o varios archivos aquí",
|
||||
"bulk_drop_sub": "PDF · hasta 50 MB por archivo",
|
||||
"bulk_count_pill": "Se crearán {count}",
|
||||
"bulk_save_cta_one": "Guardar →",
|
||||
"bulk_save_cta": "Guardar {count} →",
|
||||
"bulk_discard_all": "Descartar todo",
|
||||
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
|
||||
"bulk_add_more": "Añadir más",
|
||||
"bulk_scope_per_file_label": "Solo este archivo",
|
||||
"bulk_scope_shared_label": "Para todos los {count}",
|
||||
"bulk_title_suggested_hint": "Sugerencia del nombre de archivo — haz clic para editar",
|
||||
"bulk_switcher_prev": "Archivo anterior",
|
||||
"bulk_switcher_next": "Archivo siguiente",
|
||||
"bulk_file_error_chip_label": "Error al subir",
|
||||
"bulk_upload_progress": "{done} de {total} subidos",
|
||||
"bulk_partial_success": "{created} creados, {failed} fallidos",
|
||||
"bulk_all_failed": "Todos los uploads fallaron",
|
||||
"bulk_drop_desc": "Se crea un documento separado por archivo. El título se rellena desde el nombre del archivo — el resto de campos se aplican a todos.",
|
||||
"bulk_select_files": "Seleccionar archivos",
|
||||
"bulk_drop_zone_label": "Soltar archivos aquí",
|
||||
"bulk_remove_file": "Eliminar",
|
||||
"bulk_title_single": "Nuevo Documento",
|
||||
"bulk_title_multi": "Nuevos Documentos"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
||||
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
|
||||
reducedMotion: 'reduce', // prevents SMIL/CSS animations from flaking tests
|
||||
screenshot: 'on', // always capture screenshots
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure'
|
||||
|
||||
27
frontend/src/lib/components/BackButton.svelte
Normal file
27
frontend/src/lib/components/BackButton.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
let { class: cls = 'mb-4', showLabel = true }: { class?: string; showLabel?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => history.back()}
|
||||
aria-label={!showLabel ? m.btn_back() : undefined}
|
||||
class="group {cls} inline-flex min-h-[44px] items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors outline-none hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
class="{showLabel ? 'mr-2' : ''} h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
{#if showLabel}{m.btn_back()}{/if}
|
||||
</button>
|
||||
43
frontend/src/lib/components/BackButton.svelte.spec.ts
Normal file
43
frontend/src/lib/components/BackButton.svelte.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import BackButton from './BackButton.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('BackButton', () => {
|
||||
it('renders a button with "Zurück" text', async () => {
|
||||
render(BackButton);
|
||||
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls history.back() when clicked', async () => {
|
||||
const backSpy = vi.spyOn(history, 'back').mockImplementation(() => {});
|
||||
render(BackButton);
|
||||
|
||||
await page.getByRole('button', { name: /zurück/i }).click();
|
||||
|
||||
expect(backSpy).toHaveBeenCalledOnce();
|
||||
backSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applies mb-4 by default', async () => {
|
||||
render(BackButton);
|
||||
const btn = document.querySelector('button');
|
||||
expect(btn?.className).toContain('mb-4');
|
||||
});
|
||||
|
||||
it('applies custom class prop instead of default', async () => {
|
||||
render(BackButton, { props: { class: 'mr-3 md:hidden' } });
|
||||
const btn = document.querySelector('button');
|
||||
expect(btn?.className).toContain('mr-3');
|
||||
expect(btn?.className).not.toContain('mb-4');
|
||||
});
|
||||
|
||||
it('hides label text and sets aria-label when showLabel is false', async () => {
|
||||
render(BackButton, { props: { showLabel: false } });
|
||||
const btn = document.querySelector('button');
|
||||
expect(btn?.textContent?.trim()).toBe('');
|
||||
expect(btn?.getAttribute('aria-label')).toMatch(/zurück/i);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user