Compare commits
30 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f887f12f5 | ||
|
|
33a1db5d77 | ||
|
|
649b6b447c | ||
| fa510f3991 | |||
|
|
3ba5ae982b | ||
|
|
82979be705 | ||
|
|
a63b8115a1 | ||
|
|
ace9602f6e | ||
|
|
9716319aad | ||
|
|
afee9df8c0 | ||
| 49d8ab78b4 | |||
|
|
109202246e | ||
| 273a97046a | |||
|
|
f57e59b53c | ||
|
|
07771a7b34 | ||
|
|
4d4266ba99 | ||
|
|
446611e3cc | ||
|
|
9118a10e4b | ||
|
|
11bcaf7cdb | ||
|
|
cd238285ae | ||
|
|
ec0e4dfa45 | ||
|
|
d134990343 | ||
|
|
21b1b3b835 | ||
|
|
33aff36867 | ||
|
|
e18282318a | ||
|
|
c6fe61f06b | ||
|
|
182d014971 | ||
|
|
dc9d1d52b3 | ||
| 8558567688 | |||
|
|
6dae4fe428 |
@@ -229,9 +229,14 @@ jobs:
|
|||||||
name: Backend Unit Tests
|
name: Backend Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
|
# CI runs against the root-server Docker daemon (29.x). This API pin is a harmless
|
||||||
|
# carry-over from the old NAS runner (Docker 24.x, max API 1.43); safe to drop later.
|
||||||
|
DOCKER_API_VERSION: "1.43"
|
||||||
DOCKER_HOST: unix:///var/run/docker.sock
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
# Ryuk (Testcontainers' out-of-process reaper) is intentionally LEFT ENABLED so it
|
||||||
|
# removes each run's containers after the JVM exits. Disabling it forced the in-JVM
|
||||||
|
# reaper, which hung at JVM shutdown and leaked Postgres containers run-over-run until
|
||||||
|
# the daemon degraded and the fork timed out at teardown — see #848.
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -192,17 +192,52 @@ jobs:
|
|||||||
REPO="${{ github.repository }}"
|
REPO="${{ github.repository }}"
|
||||||
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
|
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
|
||||||
|
|
||||||
|
# --- Gitea API helper ---
|
||||||
|
# api METHOD URL [extra curl args...] — authenticated Gitea API call.
|
||||||
|
# `curl -sf` collapses every HTTP >=400 into a bare "exit 22", which
|
||||||
|
# surfaces as an opaque step failure (issue #839). Instead we read the
|
||||||
|
# status code and, on a >=400 response, print an actionable ::error::
|
||||||
|
# to stderr (so a calling command substitution does not swallow it) and
|
||||||
|
# return 1 — `set -e` then still fails the step. The token is never
|
||||||
|
# echoed (no set -x; never placed in the message).
|
||||||
|
api() {
|
||||||
|
local method="$1" url="$2"; shift 2
|
||||||
|
local resp http
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -X "$method" \
|
||||||
|
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" "$@" -- "$url")
|
||||||
|
http=${resp##*$'\n'}
|
||||||
|
printf '%s' "${resp%$'\n'*}"
|
||||||
|
case "$http" in
|
||||||
|
2*|3*) return 0 ;;
|
||||||
|
401|403)
|
||||||
|
echo "::error::Gitea returned HTTP $http for $method ${url%%\?*} — the NIGHTLY_AUDIT_TOKEN secret is missing, expired, or lacks issue read+write scope; recreate the renovate_bot PAT and update the secret." >&2
|
||||||
|
return 1 ;;
|
||||||
|
*)
|
||||||
|
echo "::error::Gitea returned HTTP ${http:-(none)} for $method ${url%%\?*}." >&2
|
||||||
|
return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# --- Self-test (mirrors ci.yml §Assert pattern) ---
|
# --- Self-test (mirrors ci.yml §Assert pattern) ---
|
||||||
# Tests the exact jq test() call used in the dedupe step, before any
|
# Runs before any real API call so broken logic fails loudly early:
|
||||||
# API call, so a broken matcher fails loudly early rather than silently
|
# (a) the jq title matcher used by the dedupe step — proves the regex
|
||||||
# opening duplicate issues. Proves the regex only — create-vs-update
|
# only; the create-vs-update decision is exercised by the
|
||||||
# decision is exercised by the workflow_dispatch AC.
|
# workflow_dispatch AC;
|
||||||
|
# (b) the api helper's HTTP-status handling, driven by a mocked curl so
|
||||||
|
# it needs no network — proves a 2xx returns the body and a >=400
|
||||||
|
# fails with an ::error:: instead of an opaque exit 22.
|
||||||
echo "{\"title\": \"${MARKER}\"}" \
|
echo "{\"title\": \"${MARKER}\"}" \
|
||||||
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
||||||
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
||||||
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
||||||
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
||||||
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
|
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
|
||||||
|
( curl() { printf 'OK\n200'; }; [ "$(api GET selftest)" = "OK" ] ) \
|
||||||
|
|| { echo "FAIL: self-test — api helper dropped body on HTTP 200"; exit 1; }
|
||||||
|
( curl() { printf 'nope\n401'; }
|
||||||
|
if api GET selftest >/dev/null 2>/tmp/api_selftest_err; then exit 1; fi
|
||||||
|
grep -q '::error::' /tmp/api_selftest_err ) \
|
||||||
|
|| { echo "FAIL: self-test — api helper did not emit ::error:: on HTTP 401"; exit 1; }
|
||||||
echo "Self-test passed."
|
echo "Self-test passed."
|
||||||
|
|
||||||
# --- Run audit ---
|
# --- Run audit ---
|
||||||
@@ -237,8 +272,7 @@ jobs:
|
|||||||
# Renovate vuln PRs also carry the "security" label, so >1 open
|
# Renovate vuln PRs also carry the "security" label, so >1 open
|
||||||
# "security" issue WILL occur. Title-match (not just label) ensures
|
# "security" issue WILL occur. Title-match (not just label) ensures
|
||||||
# we deduplicate only our own tracking issue.
|
# we deduplicate only our own tracking issue.
|
||||||
OPEN_ISSUES=$(curl -sf \
|
OPEN_ISSUES=$(api GET \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
||||||
|
|
||||||
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
||||||
@@ -255,11 +289,10 @@ jobs:
|
|||||||
--arg run_url "$RUN_URL" \
|
--arg run_url "$RUN_URL" \
|
||||||
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
||||||
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
||||||
curl -sf -X PATCH \
|
api PATCH \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD" \
|
-d "$PAYLOAD" > /dev/null
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
|
|
||||||
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
||||||
else
|
else
|
||||||
# Closed prior issue that recurs → new issue (not reopened).
|
# Closed prior issue that recurs → new issue (not reopened).
|
||||||
@@ -268,24 +301,21 @@ jobs:
|
|||||||
--arg title "$MARKER" \
|
--arg title "$MARKER" \
|
||||||
--arg body "$ISSUE_BODY" \
|
--arg body "$ISSUE_BODY" \
|
||||||
'{"title": $title, "body": $body}')
|
'{"title": $title, "body": $body}')
|
||||||
CREATED=$(curl -sf -X POST \
|
CREATED=$(api POST \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD" \
|
-d "$PAYLOAD")
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
|
|
||||||
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
||||||
echo "Opened new tracking issue #${NEW_NUMBER}"
|
echo "Opened new tracking issue #${NEW_NUMBER}"
|
||||||
|
|
||||||
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
||||||
LABEL_IDS=$(curl -sf \
|
LABEL_IDS=$(api GET \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
||||||
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
||||||
curl -sf -X POST \
|
api POST \
|
||||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"labels\": $LABEL_IDS}" \
|
-d "{\"labels\": $LABEL_IDS}" > /dev/null
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit "$AUDIT_EXIT"
|
exit "$AUDIT_EXIT"
|
||||||
|
|||||||
@@ -139,6 +139,25 @@
|
|||||||
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done |
|
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done |
|
||||||
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done |
|
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done |
|
||||||
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done |
|
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done |
|
||||||
|
| REQ-001 | store relationship from/to as nullable LocalDate + NOT-NULL DatePrecision (default UNKNOWN) | #837 | relationship-edit-dates | `person/relationship/PersonRelationship.java`, `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#yearColumnsDropped_andNamedCheckConstraintsExist`, `RelationshipServiceTest#addRelationship_persists_with_storage_truth` | Done |
|
||||||
|
| REQ-002 | V78 backfills non-null years as `{year}-01-01`/YEAR, nulls → null/UNKNOWN, rows preserved | #837 | relationship-edit-dates | `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#backfill_fromYearAndToYear_becomeYearPrecisionDates`, `#backfill_bothNull_leavesDatesNullAndPrecisionsUnknown`, `#backfill_preservesRowCount` | Done |
|
||||||
|
| REQ-003 | named DB CHECKs: coherence both ends + fromDate ≤ toDate | #837 | relationship-edit-dates | `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#orderCheckConstraint_rejectsToDateBeforeFromDate`, `#coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision` | Done |
|
||||||
|
| REQ-004 | PUT updates the relationship → 200 RelationshipDTO | #837 | relationship-edit-dates | `person/relationship/RelationshipController#updateRelationship`, `RelationshipService#updateRelationship` | `RelationshipControllerTest#updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user`, `RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes`, `page.server.spec.ts#updateRelationship PUTs to the relId path with the new body` | Done |
|
||||||
|
| REQ-005 | create + update rejected with 403 without WRITE_ALL | #837 | relationship-edit-dates | `person/relationship/RelationshipController` (`@RequirePermission`) | `RelationshipControllerTest#updateRelationship_returns403_for_READ_ALL_only_user`, `#addRelationship_returns403_for_user_with_READ_ALL_only` | Done |
|
||||||
|
| REQ-006 | relId not existing / not owned by person → 404 RELATIONSHIP_NOT_FOUND | #837 | relationship-edit-dates | `person/relationship/RelationshipService#loadOwnedRelationship` | `RelationshipServiceTest#updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person`, `RelationshipServiceIntegrationTest#updateRelationship_throws_404_when_rel_belongs_to_different_person` | Done |
|
||||||
|
| REQ-007 | update with relatedPersonId == {id} → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_VALIDATION_ERROR_on_self_relation` | Done |
|
||||||
|
| REQ-008 | resulting (person, relatedPerson, type) duplicate → 409 DUPLICATE_RELATIONSHIP | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_DUPLICATE_when_db_constraint_violated` | Done |
|
||||||
|
| REQ-009 | update to PARENT_OF with reverse PARENT_OF present → 409 CIRCULAR_RELATIONSHIP | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists` | Done |
|
||||||
|
| REQ-010 | toDate before fromDate → 400 INVALID_RELATIONSHIP_DATES | #837 | relationship-edit-dates | `person/relationship/RelationshipService#validateRelationshipDates`, `exception/ErrorCode`, `frontend/src/lib/shared/errors.ts` | `RelationshipServiceTest#addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate`, `#updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate` | Done |
|
||||||
|
| REQ-011 | date+UNKNOWN precision, or precision without date → 400 INVALID_DATE_PRECISION | #837 | relationship-edit-dates | `person/relationship/RelationshipService#requireDatePrecisionCoherence` | `RelationshipServiceTest#addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown`, `#addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date` | Done |
|
||||||
|
| REQ-012 | invalid enum / missing relatedPersonId·relationType / notes > 2000 → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | `person/relationship/RelationshipUpsertRequest` (Bean Validation), `RelationshipController` | `RelationshipControllerTest#updateRelationship_returns400_when_relationType_is_unknown_value`, `#addRelationship_returns400_when_relationType_is_unknown_value` | Done |
|
||||||
|
| REQ-013 | updating into a family type flags both endpoints (additive) | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_marks_both_endpoints_family_when_updated_to_family_type` | Done |
|
||||||
|
| REQ-014 | persist + display notes on create, update, read and edit views | #837 | relationship-edit-dates | `person/relationship/RelationshipService`, `frontend/.../AddRelationshipForm.svelte`, `routes/persons/[id]/PersonRelationshipsCard.svelte` | `RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes`, `AddRelationshipForm.svelte.spec.ts#round-trips the notes into the textarea`, `PersonRelationshipsCard.svelte.test.ts#shows the notes line` | Done |
|
||||||
|
| REQ-015 | detail view shows the date range at its precision; no dates → no date line | #837 | relationship-edit-dates | `frontend/src/lib/person/relationshipDates.ts`, `routes/persons/[id]/PersonRelationshipsCard.svelte` | `relationshipDates.spec.ts`, `PersonRelationshipsCard.svelte.test.ts#renders the date range at its stored precision`, `#renders no date line when the relationship has no dates` | Done |
|
||||||
|
| REQ-016 | edit affordance opens a form pre-filled with type/person/dates+precision/notes; precision DAY/MONTH/YEAR | #837 | relationship-edit-dates | `frontend/.../AddRelationshipForm.svelte`, `RelationshipDateField.svelte`, `RelationshipChip.svelte` | `AddRelationshipForm.svelte.spec.ts#pre-fills the from-date as dd.mm.yyyy`, `#offers only DAY/MONTH/YEAR in each precision select`, `RelationshipChip.svelte.spec.ts#shows an Edit affordance with an accessible name when canWrite and onEdit` | Done |
|
||||||
|
| REQ-017 | derived Heirat sources SPOUSE_OF.fromDate + fromDatePrecision | #837 | relationship-edit-dates | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_day_precision_heirat_from_spouse_fromDate` | Done |
|
||||||
|
| REQ-018 | unauthenticated PUT → 401, no row modified | #837 | relationship-edit-dates | `person/relationship/RelationshipController` (SecurityConfig) | `RelationshipControllerTest#updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service` | Done |
|
||||||
|
| REQ-019 | while a create/update request is in flight, submit is disabled + shows a progress indicator | #837 | relationship-edit-dates | `frontend/src/lib/person/relationship/AddRelationshipForm.svelte` | `AddRelationshipForm.svelte.spec.ts#disables the submit and shows a progress spinner while a submit is in flight` | Done |
|
||||||
| REQ-001 | TimelineEntryDTO carries rootTagId/rootTagName/rootTagColor for LETTER entries, assembled in-transaction (id+name+token only) | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `timeline/TimelineService#mapDocument` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag` | Done |
|
| REQ-001 | TimelineEntryDTO carries rootTagId/rootTagName/rootTagColor for LETTER entries, assembled in-transaction (id+name+token only) | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `timeline/TimelineService#mapDocument` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag` | Done |
|
||||||
| REQ-002 | the three root-tag fields are nullable and not `@Schema(requiredMode = REQUIRED)` | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `frontend/src/lib/generated/api.ts` (optional) | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields` (+ regenerated `api.ts` shows `rootTag*?`) | Done |
|
| REQ-002 | the three root-tag fields are nullable and not `@Schema(requiredMode = REQUIRED)` | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `frontend/src/lib/generated/api.ts` (optional) | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields` (+ regenerated `api.ts` shows `rootTag*?`) | Done |
|
||||||
| REQ-003 | primary tag = root ancestor of the alphabetically-first assigned tag, resolved via TagService | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `TagServiceTest#resolveRootTags_walksChildToRoot_withRootColor`, `TagServiceIntegrationTest#resolveRootTags_walksPersistedChainToRoot_withRootColor` | Done |
|
| REQ-003 | primary tag = root ancestor of the alphabetically-first assigned tag, resolved via TagService | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `TagServiceTest#resolveRootTags_walksChildToRoot_withRootColor`, `TagServiceIntegrationTest#resolveRootTags_walksPersistedChainToRoot_withRootColor` | Done |
|
||||||
@@ -154,3 +173,47 @@
|
|||||||
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
|
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
|
||||||
| REQ-013 | sr-only theme label is a Paraglide key present in de/en/es | #835 | zeitstrahl-tag-chips | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl tag-chip label key is present in all locales` | Done |
|
| REQ-013 | sr-only theme label is a Paraglide key present in de/en/es | #835 | zeitstrahl-tag-chips | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl tag-chip label key is present in all locales` | Done |
|
||||||
| REQ-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |
|
| REQ-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |
|
||||||
|
| REQ-001 | TimelineFilters is presentation-only (3 $bindable layer booleans + onChange); no goto/url.searchParams/api.GET/fetch | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#renders the three layer toggles with accessible names`, `#reflects a layer as pressed and flips it, firing onChange`; `timelineFilterBoundary.spec.ts` | Done |
|
||||||
|
| REQ-002 | route derives a client-side $derived filtered view, passes it to TimelineView; no goto/fetch on toggle | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#hides letter cards when the Letters layer is off ... with no fetch`; `timelineFilterBoundary.spec.ts` | Done |
|
||||||
|
| REQ-003 | Personal off → personal events (curated + derived life-events) hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides personal events — curated and derived`, `page.svelte.spec.ts#hides personal event cards` | Done |
|
||||||
|
| REQ-004 | Historical off → historical event entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides HISTORICAL events`, `page.svelte.spec.ts#hides historical event cards` | Done |
|
||||||
|
| REQ-005 | Letters off → letter entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides LETTER entries`, `page.svelte.spec.ts#hides letter cards` | Done |
|
||||||
|
| REQ-006 | zero visible → filter empty-state + one-click reset below the open bar (never blank, never generic empty) | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#shows the filtered-empty message + reset below the open bar`, `timelineFilter.spec.ts#drops year bands that become empty` | Done |
|
||||||
|
| REQ-007 | sticky "Filter (N aktiv)" trigger; N from isDefaultState/hiddenLayerCount; 44px target | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts`, `frontend/src/lib/timeline/TimelineFilters.svelte` | `timelineFilter.spec.ts#isDefaultState`, `#hiddenLayerCount`; `TimelineFilters.svelte.spec.ts#shows a plain trigger ... and a count`, `#gives the trigger a 44px touch target` | Done |
|
||||||
|
| REQ-008 | reset text button restores all layers on; visibility tracks a $derived any-layer-off flag | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#hides the reset button by default and restores all layers when activated` | Done |
|
||||||
|
| REQ-009 | prefers-reduced-motion → slide duration 0 (matchMedia guard reused from documents/[id]/+page.svelte:57) | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | manual reduced-motion check + svelte-autofixer/code review (guard reused, only the slide `duration` zeroed) | Done |
|
||||||
|
| REQ-010 | 8 timeline_filter_* keys in de/en/es; trigger vs trigger_active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |
|
||||||
|
| REQ-001 | WRITE_ALL viewer → "Ereignis hinzufügen" link to /zeitstrahl/events/new in the wrapping Zeitstrahl header | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/messages/{de,en,es}.json` | `zeitstrahl/page.svelte.spec.ts#renders the add-event CTA in a wrapping header when the viewer can write` | Done |
|
||||||
|
| REQ-002 | viewer without WRITE_ALL → no add-event affordance on /zeitstrahl | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#renders no add-event CTA when the viewer cannot write` | Done |
|
||||||
|
| REQ-003 | WRITE_ALL viewer → person-page "Ereignis für diese Person" link to /zeitstrahl/events/new?personId={id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/messages/{de,en,es}.json` | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` | Done |
|
||||||
|
| REQ-004 | viewer without WRITE_ALL → no add-event affordance on /persons/{id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte` | `PersonCard.svelte.spec.ts#renders no add-event link to a reader` | Done |
|
||||||
|
| REQ-005 | WRITE_ALL → EventPill edit link /zeitstrahl/events/{eventId}/edit for a curated PERSONAL event | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#shows an edit affordance for a curated PERSONAL event when canWrite is true` | Done |
|
||||||
|
| REQ-006 | WRITE_ALL → WorldBand edit link /zeitstrahl/events/{eventId}/edit for a curated HISTORICAL event (new inline ✎) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#shows an edit affordance for a curated HISTORICAL event when canWrite is true`, `#mirrors the EventPill pencil` | Done |
|
||||||
|
| REQ-007 | viewer without WRITE_ALL → neither EventPill nor WorldBand renders an edit link | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `EventPill.svelte.spec.ts#renders no edit affordance for a curated PERSONAL event when canWrite is false`, `WorldBand.svelte.spec.ts#renders no edit affordance for a curated HISTORICAL event when canWrite is false`, `TimelineView.svelte.spec.ts#renders no edit links in either path when canWrite is false` | Done |
|
||||||
|
| REQ-008 | derived OR null eventId → no edit link regardless of permission (contract preserved) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `EventPill.svelte.spec.ts#shows no edit affordance when eventId is null even with canWrite`, `#shows no edit affordance for a derived event even with canWrite`, `WorldBand.svelte.spec.ts#shows no edit affordance when eventId is null even with canWrite` | Done |
|
||||||
|
| REQ-009 | TimelineView threads canWrite through the year-band path and the undated bucket (identical gate) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `TimelineView.svelte.spec.ts#threads canWrite to a curated event in both a year band and the undated bucket`, `#threads canWrite to a curated HISTORICAL world band in both paths` | Done |
|
||||||
|
| REQ-010 | person add-event opens #781 create form prefilled with the person, returns to /persons/{id} on save | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (#781) | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` (URL), `zeitstrahl/events/new/page.server.spec.ts#redirects to /persons/{id} when originPersonId is a valid UUID` (#781) | Done |
|
||||||
|
| REQ-011 | non-curator direct nav to /zeitstrahl/events/new or /{id}/edit → 403 (existing #781 route guard, regression) | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (#781, unchanged — both share `requireWriteAll`) | `zeitstrahl/events/new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `zeitstrahl/events/[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
|
||||||
|
| REQ-001 | `/zeitstrahl` renders a single chronological timeline with no grouping-mode control (no toggle/Thema/drawer) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/TimelineView.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`; absence of `GroupingControl`/`LetterBucket`/`BucketHeaderChip` (never ported) | Done |
|
||||||
|
| REQ-002 | curated event with ≥1 same-year linked letter → contained card (event header + glyph/title/date·provenance/edit + compact letter cards) | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `EventCluster.svelte.spec.ts#renders a data-testid event-card with the event title once`, `#shows the event-edit link for a curator`, `#renders its letters as compact a.lcard.ev cards`; `YearBand.svelte.spec.ts#renders a curated event with a same-year linked letter as one event-card, title once, no separate pill` | Done |
|
||||||
|
| REQ-003 | event card body > 5 letters → first 5 + keyboard-operable show-more/less toggle (aria-expanded, ≥44px) | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/eventClustering.ts` (`CLUSTER_PREVIEW=5`) | `EventCluster.svelte.spec.ts#shows the first 5 of 8 letters + a show-more toggle that expands to 8 then back to 5`, `#renders no show-more toggle when the cluster holds 5 or fewer letters` | Done |
|
||||||
|
| REQ-004 | linked letters in a year other than the event's band → labeled ✉ text-header card (no pill, no edit link) per such year | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `EventCluster.svelte.spec.ts#renders a cross-year text header (✉ title, no event-edit, no pill) when no event is given`; `YearBand.svelte.spec.ts#renders a cross-year cluster (event not in this band) as a ✉ text-header card holding it` | Done |
|
||||||
|
| REQ-005 | event/derived/world-band with no linked letters → existing plain pill/world-band (unchanged) | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `YearBand.svelte.spec.ts#renders a curated event with NO linked letters as a plain EventPill, no card` | Done |
|
||||||
|
| REQ-006 | letter linked to no curated event → loose chronological letter (alternating; density strip past 12) | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `eventClustering.spec.ts#keeps a letter with no linkedEventId loose`; `YearBand.svelte.spec.ts#renders loose (unlinked) letters in a sparse year as alternating .letter-row, no card` | Done |
|
||||||
|
| REQ-007 | loose-letter layout + density strip count ONLY non-event-linked letters; clustered letter never also loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `eventClustering.spec.ts#places each letter in exactly one place`; `YearBand.svelte.spec.ts#counts only loose letters in the density strip; event letters stay in the card` | Done |
|
||||||
|
| REQ-008 | letter whose only linking event is filtered out (absent from lookup) → loose, never re-introduces the event (filter-then-cluster) | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`splitYearLetters` filters via `eventLookup`) | `eventClustering.spec.ts#keeps a letter whose linkedEventId is absent from the lookup loose (filter-then-cluster, REQ-008)` | Done |
|
||||||
|
| REQ-009 | `TimelineEntryDTO` carries nullable `linkedEventId` for LETTER entries, one batched pass, no new column/migration, not @Schema(REQUIRED); a multi-event letter links deterministically (earliest date, then id) | #850 | inline-event-clustering | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#resolveLetterEventLinks`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#letter_in_a_curated_events_documents_carries_that_events_id`, `#letter_in_no_curated_event_has_null_linkedEventId`, `#multi_event_letter_links_deterministically_to_the_earliest_event` | Done |
|
||||||
|
| REQ-010 | event/letter/sender/receiver text via Svelte `{...}` escaping; no `{@html}` anywhere in `lib/timeline/` (grep gate, CWE-79) | #850 | inline-event-clustering | `frontend/src/lib/timeline/*.svelte` | `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive`; `EventCluster.svelte.spec.ts#renders an HTML-bearing event title verbatim as text, never as markup` | Done |
|
||||||
|
| REQ-011 | wrapping header keeps #842 add-event CTA + #780 filter trigger; meta-line drops the grouping segment | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`, `#drops the letters segment instead of showing "0 Briefe"` (no "Gruppierung") | Done |
|
||||||
|
| REQ-012 | show-more/less labels are new Paraglide keys in de/en/es; unused #827 grouping/Thema keys removed | #850 | inline-event-clustering | `frontend/messages/{de,en,es}.json`, `frontend/src/lib/timeline/EventCluster.svelte` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales` (now asserts `timeline_bucket_show_more`/`_less`; `timeline_grouping_date` removed) | Done |
|
||||||
|
| REQ-013 | `GET /api/timeline` failure → existing localized error state via `getErrorMessage(code)` (unchanged #779) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` (unchanged error path) | covered by #779 `zeitstrahl` error-state tests (regression — no change) | Done |
|
||||||
|
| REQ-014 | HISTORICAL curated event with ≥1 linked letter keeps its full-width WorldBand — never clusters into a card (preserves #779 REQ-009); its letters stay loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`buildEventLookup` excludes HISTORICAL) | `eventClustering.spec.ts#excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)`; `TimelineView.svelte.spec.ts#keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)` | Done |
|
||||||
|
| REQ-015 | a cross-year ✉ card is placed at its earliest linked letter's chronological position in the band — never appended after later-dated loose letters | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#interleaves a cross-year card before a later-dated loose letter in the same band (REQ-015)` | Done |
|
||||||
|
| REQ-001 | `TimelineEvent.description` flows through `TimelineEntryDTO` to the frontend; null for letters and derived events | #844 | event-note-display | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#mapEvent`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#mapEvent_populates_description_from_event`, `#mapEvent_leaves_description_null_when_event_has_none`, `#mapDocument_leaves_description_null_for_letter`; `TimelineControllerTest#timelineIncludesEventDescription` | Done |
|
||||||
|
| REQ-002 | description text is HTML-escaped; no `{@html}` — Svelte `{...}` interpolation ensures XSS safety (CWE-79) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#escapesHtml — renders XSS payload as inert text, no injected element` | Done |
|
||||||
|
| REQ-003 | newlines in the description are preserved visually via `white-space: pre-line` | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#preservesLineBreaks — note element carries whitespace-pre-line class` | Done |
|
||||||
|
| REQ-004 | description renders below the title/subtitle line in EventPill (PERSONAL) and WorldBand (HISTORICAL) | #844 | event-note-display | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `e2e/zeitstrahl-note.spec.ts#PERSONAL curated event note appears below its title`, `#HISTORICAL curated event note appears below its title` | Done |
|
||||||
|
| REQ-005 | description longer than 3 lines is clamped and shows a disclosure toggle with aria-expanded=false (show more) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#clampsAndShowsToggle — long note shows "mehr anzeigen" with aria-expanded=false` | Done |
|
||||||
|
| REQ-006 | short description (≤ 3 lines) renders fully with no toggle | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#shortNoteNoToggle — a one-line note renders fully with no disclosure control` | Done |
|
||||||
|
| REQ-007 | clicking the toggle expands the note (aria-expanded=true, "show less"); clicking again collapses it | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#toggleExpandsCollapses — click expands, re-click collapses` | Done |
|
||||||
|
| REQ-008 | null, empty, or blank-only description renders nothing (no note element in DOM) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#blankNoteRendersNothing — null/empty string/blank-only string produces no note element` (3 cases) | Done |
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,12 @@
|
|||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
|
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
|
||||||
|
<!-- Grace period after the test JVM calls System.exit(0). The 30s default is too
|
||||||
|
short: the single reused fork closes ~32 cached Spring contexts at shutdown,
|
||||||
|
each tearing down a Testcontainers Postgres + HikariCP pool, which overruns 30s
|
||||||
|
and makes Surefire kill the fork (BUILD FAILURE despite 0 test failures). This is
|
||||||
|
a different knob from forkedProcessTimeoutInSeconds above. See issue #848. -->
|
||||||
|
<forkedProcessExitTimeoutInSeconds>120</forkedProcessExitTimeoutInSeconds>
|
||||||
<systemPropertyVariables>
|
<systemPropertyVariables>
|
||||||
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
|
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
|
||||||
</systemPropertyVariables>
|
</systemPropertyVariables>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-field validation and normalization shared by every domain that stores a
|
||||||
|
* {@link LocalDate} + {@link DatePrecision} pair — a person's life dates (ADR-039 / V76)
|
||||||
|
* and a relationship's from/to dates (ADR-044 / V78). Kept out of {@link DatePrecision}
|
||||||
|
* itself because that enum is a frozen contract mirror of the import normalizer (ADR-025)
|
||||||
|
* and must carry no behaviour.
|
||||||
|
*/
|
||||||
|
public final class DatePrecisionValidation {
|
||||||
|
|
||||||
|
private DatePrecisionValidation() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the date ⇔ precision coherence the V76/V78 CHECK constraints also enforce:
|
||||||
|
* a date requires a non-{@code UNKNOWN} precision, and a non-{@code UNKNOWN} precision
|
||||||
|
* requires a date. Validated in-service so the caller gets a structured 400 instead of
|
||||||
|
* the database constraint's raw 500.
|
||||||
|
*
|
||||||
|
* @param side human-readable field label woven into the error message ("birth", "from", …)
|
||||||
|
*/
|
||||||
|
public static void requireCoherence(LocalDate date, DatePrecision precision, String side) {
|
||||||
|
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date is set but its precision is missing or UNKNOWN");
|
||||||
|
}
|
||||||
|
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date precision " + precision + " is set without a date");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A null precision means "no precision recorded" → {@link DatePrecision#UNKNOWN}. */
|
||||||
|
public static DatePrecision normalize(DatePrecision precision) {
|
||||||
|
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,8 @@ public enum ErrorCode {
|
|||||||
CIRCULAR_RELATIONSHIP,
|
CIRCULAR_RELATIONSHIP,
|
||||||
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
||||||
DUPLICATE_RELATIONSHIP,
|
DUPLICATE_RELATIONSHIP,
|
||||||
|
/** A relationship's toDate is before its fromDate. 400 */
|
||||||
|
INVALID_RELATIONSHIP_DATES,
|
||||||
|
|
||||||
// --- Geschichten (Stories) ---
|
// --- Geschichten (Stories) ---
|
||||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.person.PersonType;
|
|||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -126,7 +126,7 @@ public class PersonTreeImporter {
|
|||||||
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
||||||
try {
|
try {
|
||||||
relationshipService.addRelationship(person,
|
relationshipService.addRelationship(person,
|
||||||
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null));
|
new RelationshipUpsertRequest(related, RelationType.valueOf(type), null, null, null, null, null));
|
||||||
return true;
|
return true;
|
||||||
} catch (DomainException e) {
|
} catch (DomainException e) {
|
||||||
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
|||||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -448,41 +449,28 @@ public class PersonService {
|
|||||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||||
.birthDate(dto.getBirthDate())
|
.birthDate(dto.getBirthDate())
|
||||||
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
.birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
|
||||||
.deathDate(dto.getDeathDate())
|
.deathDate(dto.getDeathDate())
|
||||||
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
.deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
|
||||||
.generation(dto.getGeneration())
|
.generation(dto.getGeneration())
|
||||||
.build();
|
.build();
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
||||||
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
|
// user gets a structured ErrorCode instead of a raw constraint-violation 500. Coherence
|
||||||
|
// is shared with the relationship domain (DatePrecisionValidation); only the order check
|
||||||
|
// (and its BIRTH_AFTER_DEATH code) is life-date specific.
|
||||||
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
||||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||||
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
|
||||||
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
|
||||||
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||||
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||||
"Birth date " + birthDate + " is after death date " + deathDate);
|
"Birth date " + birthDate + " is after death date " + deathDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
|
|
||||||
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
|
||||||
side + " date is set but its precision is missing or UNKNOWN");
|
|
||||||
}
|
|
||||||
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
|
||||||
side + " date precision " + precision + " is set without a date");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
|
||||||
return precision == null ? DatePrecision.UNKNOWN : precision;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
@@ -499,9 +487,9 @@ public class PersonService {
|
|||||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||||
person.setBirthDate(dto.getBirthDate());
|
person.setBirthDate(dto.getBirthDate());
|
||||||
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
|
||||||
person.setDeathDate(dto.getDeathDate());
|
person.setDeathDate(dto.getDeathDate());
|
||||||
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
person.setDeathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()));
|
||||||
// Form path: a human can clear generation back to null. Unlike the importer
|
// Form path: a human can clear generation back to null. Unlike the importer
|
||||||
// which routes through preferHuman, we write the DTO value verbatim.
|
// which routes through preferHuman, we write the DTO value verbatim.
|
||||||
person.setGeneration(dto.getGeneration());
|
person.setGeneration(dto.getGeneration());
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -39,11 +41,25 @@ public class PersonRelationship {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private RelationType relationType;
|
private RelationType relationType;
|
||||||
|
|
||||||
@Column(name = "from_year")
|
// Start/end of the relationship (wedding, employment start, …). The date column
|
||||||
private Integer fromYear;
|
// is nullable, the precision column is NOT NULL with UNKNOWN meaning "no date" —
|
||||||
|
// the V78 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN) and
|
||||||
|
// from_date <= to_date. Mirrors Person.{birth,death}Date (ADR-039 / ADR-044).
|
||||||
|
private LocalDate fromDate;
|
||||||
|
|
||||||
@Column(name = "to_year")
|
@Enumerated(EnumType.STRING)
|
||||||
private Integer toYear;
|
@Column(name = "from_date_precision", nullable = false, length = 16)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private DatePrecision fromDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
|
private LocalDate toDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "to_date_precision", nullable = false, length = 16)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private DatePrecision toDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
@Column(length = 2000)
|
@Column(length = 2000)
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
@@ -63,11 +63,20 @@ public class RelationshipController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<RelationshipDTO> addRelationship(
|
public ResponseEntity<RelationshipDTO> addRelationship(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@Valid @RequestBody CreateRelationshipRequest dto) {
|
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
||||||
return ResponseEntity.status(HttpStatus.CREATED)
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
.body(relationshipService.addRelationship(id, dto));
|
.body(relationshipService.addRelationship(id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/persons/{id}/relationships/{relId}")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public RelationshipDTO updateRelationship(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@PathVariable UUID relId,
|
||||||
|
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
||||||
|
return relationshipService.updateRelationship(id, relId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
@@ -96,65 +98,129 @@ public class RelationshipService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) {
|
||||||
if (personId.equals(dto.relatedPersonId())) {
|
requireNotSelf(personId, dto.relatedPersonId());
|
||||||
throw DomainException.badRequest(
|
|
||||||
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
|
||||||
}
|
|
||||||
Person person = personService.getById(personId);
|
Person person = personService.getById(personId);
|
||||||
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
||||||
|
|
||||||
validateYears(dto.fromYear(), dto.toYear());
|
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
|
||||||
|
requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());
|
||||||
if (dto.relationType() == RelationType.PARENT_OF
|
|
||||||
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
|
||||||
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
|
|
||||||
throw DomainException.conflict(
|
|
||||||
ErrorCode.CIRCULAR_RELATIONSHIP,
|
|
||||||
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonRelationship rel = PersonRelationship.builder()
|
PersonRelationship rel = PersonRelationship.builder()
|
||||||
.person(person)
|
.person(person)
|
||||||
.relatedPerson(relatedPerson)
|
.relatedPerson(relatedPerson)
|
||||||
.relationType(dto.relationType())
|
.relationType(dto.relationType())
|
||||||
.fromYear(dto.fromYear())
|
.fromDate(dto.fromDate())
|
||||||
.toYear(dto.toYear())
|
.fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
|
||||||
|
.toDate(dto.toDate())
|
||||||
|
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
|
||||||
.notes(blankToNull(dto.notes()))
|
.notes(blankToNull(dto.notes()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
PersonRelationship saved;
|
PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
|
||||||
try {
|
flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId());
|
||||||
// saveAndFlush so the unique_rel constraint violates synchronously and is
|
|
||||||
// caught here, not at commit time outside the @Transactional boundary.
|
|
||||||
saved = relationshipRepository.saveAndFlush(rel);
|
|
||||||
} catch (DataIntegrityViolationException e) {
|
|
||||||
throw DomainException.conflict(
|
|
||||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
|
||||||
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
|
|
||||||
}
|
|
||||||
// Family-graph edges imply both endpoints are family members. Idempotent: the
|
|
||||||
// setter is a no-op when the person is already flagged, so re-imports stay clean.
|
|
||||||
if (FAMILY_RELATION_TYPES.contains(dto.relationType())) {
|
|
||||||
personService.setFamilyMember(person.getId(), true);
|
|
||||||
personService.setFamilyMember(relatedPerson.getId(), true);
|
|
||||||
}
|
|
||||||
return toDTO(saved);
|
return toDTO(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public RelationshipDTO updateRelationship(UUID personId, UUID relId, RelationshipUpsertRequest dto) {
|
||||||
|
PersonRelationship rel = loadOwnedRelationship(personId, relId);
|
||||||
|
|
||||||
|
// The other party from {personId}'s viewpoint cannot be {personId} itself.
|
||||||
|
requireNotSelf(personId, dto.relatedPersonId());
|
||||||
|
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
|
||||||
|
|
||||||
|
// Preserve the directed orientation: {personId} keeps whichever role (subject or
|
||||||
|
// object) it already holds on the row, and the edited "related person" takes the
|
||||||
|
// other role. So a PARENT_OF edge stays parent→child whether the curator edits it
|
||||||
|
// from the parent's page or the child's.
|
||||||
|
boolean viewpointIsSubject = personId.equals(rel.getPerson().getId());
|
||||||
|
Person viewpoint = viewpointIsSubject ? rel.getPerson() : rel.getRelatedPerson();
|
||||||
|
Person other = personService.getById(dto.relatedPersonId());
|
||||||
|
Person newSubject = viewpointIsSubject ? viewpoint : other;
|
||||||
|
Person newObject = viewpointIsSubject ? other : viewpoint;
|
||||||
|
|
||||||
|
requireNoReverseParent(newSubject.getId(), newObject.getId(), dto.relationType());
|
||||||
|
|
||||||
|
rel.setPerson(newSubject);
|
||||||
|
rel.setRelatedPerson(newObject);
|
||||||
|
rel.setRelationType(dto.relationType());
|
||||||
|
rel.setFromDate(dto.fromDate());
|
||||||
|
rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()));
|
||||||
|
rel.setToDate(dto.toDate());
|
||||||
|
rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()));
|
||||||
|
rel.setNotes(blankToNull(dto.notes()));
|
||||||
|
|
||||||
|
PersonRelationship saved = persistOrConflict(rel, newSubject.getId(), newObject.getId(), dto.relationType());
|
||||||
|
flagFamilyMembership(dto.relationType(), newSubject.getId(), newObject.getId());
|
||||||
|
return toDTO(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared create/update invariants ---------------------------------------------
|
||||||
|
|
||||||
|
// A person cannot be related to themselves, from either viewpoint.
|
||||||
|
private static void requireNotSelf(UUID viewpointId, UUID relatedPersonId) {
|
||||||
|
if (viewpointId.equals(relatedPersonId)) {
|
||||||
|
throw DomainException.badRequest(
|
||||||
|
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PARENT_OF edge must not already have its mirror (child PARENT_OF parent) stored —
|
||||||
|
// that would be a cycle. No-op for every other relation type.
|
||||||
|
private void requireNoReverseParent(UUID subjectId, UUID objectId, RelationType type) {
|
||||||
|
if (type == RelationType.PARENT_OF
|
||||||
|
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
objectId, subjectId, RelationType.PARENT_OF)) {
|
||||||
|
throw DomainException.conflict(
|
||||||
|
ErrorCode.CIRCULAR_RELATIONSHIP,
|
||||||
|
"Reverse PARENT_OF already exists between " + subjectId + " and " + objectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveAndFlush so the unique_rel constraint violates synchronously and is caught here,
|
||||||
|
// inside the @Transactional boundary, not at commit time as a raw 500.
|
||||||
|
private PersonRelationship persistOrConflict(PersonRelationship rel, UUID subjectId, UUID objectId, RelationType type) {
|
||||||
|
try {
|
||||||
|
return relationshipRepository.saveAndFlush(rel);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
throw DomainException.conflict(
|
||||||
|
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||||
|
"Relationship already exists for (" + subjectId + ", " + objectId + ", " + type + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family-graph edges imply both endpoints are family members. Idempotent (the setter is
|
||||||
|
// a no-op when already flagged, so re-imports stay clean) and additive — an edit never
|
||||||
|
// auto-unflags.
|
||||||
|
private void flagFamilyMembership(RelationType type, UUID subjectId, UUID objectId) {
|
||||||
|
if (FAMILY_RELATION_TYPES.contains(type)) {
|
||||||
|
personService.setFamilyMember(subjectId, true);
|
||||||
|
personService.setFamilyMember(objectId, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteRelationship(UUID personId, UUID relId) {
|
public void deleteRelationship(UUID personId, UUID relId) {
|
||||||
|
PersonRelationship rel = loadOwnedRelationship(personId, relId);
|
||||||
|
relationshipRepository.delete(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the row and verifies {personId} is one of its endpoints. A mismatch is 404
|
||||||
|
// (not 403): an anti-enumeration choice so a curator cannot probe relationship ids
|
||||||
|
// belonging to people they cannot see. Shared by update + delete for consistency.
|
||||||
|
private PersonRelationship loadOwnedRelationship(UUID personId, UUID relId) {
|
||||||
PersonRelationship rel = relationshipRepository.findById(relId)
|
PersonRelationship rel = relationshipRepository.findById(relId)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
||||||
|
|
||||||
UUID storageSubject = rel.getPerson().getId();
|
UUID storageSubject = rel.getPerson().getId();
|
||||||
UUID storageObject = rel.getRelatedPerson().getId();
|
UUID storageObject = rel.getRelatedPerson().getId();
|
||||||
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
||||||
throw DomainException.forbidden(
|
throw DomainException.notFound(
|
||||||
|
ErrorCode.RELATIONSHIP_NOT_FOUND,
|
||||||
"Relationship " + relId + " does not belong to person " + personId);
|
"Relationship " + relId + " does not belong to person " + personId);
|
||||||
}
|
}
|
||||||
relationshipRepository.delete(rel);
|
return rel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -173,10 +239,17 @@ public class RelationshipService {
|
|||||||
return date != null ? date.getYear() : null;
|
return date != null ? date.getYear() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
// Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
|
||||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
// user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
|
||||||
throw DomainException.badRequest(
|
// Coherence is shared with the person domain (DatePrecisionValidation); only the order
|
||||||
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
// check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific.
|
||||||
|
private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
|
||||||
|
LocalDate toDate, DatePrecision toPrecision) {
|
||||||
|
DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from");
|
||||||
|
DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to");
|
||||||
|
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
|
||||||
|
"toDate " + toDate + " is before fromDate " + fromDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +267,10 @@ public class RelationshipService {
|
|||||||
yearOf(rp.getBirthDate()),
|
yearOf(rp.getBirthDate()),
|
||||||
yearOf(rp.getDeathDate()),
|
yearOf(rp.getDeathDate()),
|
||||||
r.getRelationType(),
|
r.getRelationType(),
|
||||||
r.getFromYear(),
|
r.getFromDate(),
|
||||||
r.getToYear(),
|
r.getFromDatePrecision(),
|
||||||
|
r.getToDate(),
|
||||||
|
r.getToDatePrecision(),
|
||||||
r.getNotes());
|
r.getNotes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record CreateRelationshipRequest(
|
|
||||||
@NotNull UUID relatedPersonId,
|
|
||||||
@NotNull RelationType relationType,
|
|
||||||
Integer fromYear,
|
|
||||||
Integer toYear,
|
|
||||||
@Size(max = 2000) String notes
|
|
||||||
) {}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +28,9 @@ public record RelationshipDTO(
|
|||||||
Integer relatedPersonBirthYear,
|
Integer relatedPersonBirthYear,
|
||||||
Integer relatedPersonDeathYear,
|
Integer relatedPersonDeathYear,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
||||||
Integer fromYear,
|
LocalDate fromDate,
|
||||||
Integer toYear,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision,
|
||||||
|
LocalDate toDate,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision,
|
||||||
String notes
|
String notes
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for both creating and updating a relationship — the fields are
|
||||||
|
* identical, so one record serves {@code POST} and {@code PUT} (DRY). A null
|
||||||
|
* {@code *DatePrecision} is normalized to {@code UNKNOWN} by the service; the
|
||||||
|
* service then enforces coherence (date ⇔ non-UNKNOWN precision) and order
|
||||||
|
* (fromDate ≤ toDate).
|
||||||
|
*/
|
||||||
|
public record RelationshipUpsertRequest(
|
||||||
|
@NotNull UUID relatedPersonId,
|
||||||
|
@NotNull RelationType relationType,
|
||||||
|
LocalDate fromDate,
|
||||||
|
DatePrecision fromDatePrecision,
|
||||||
|
LocalDate toDate,
|
||||||
|
DatePrecision toDatePrecision,
|
||||||
|
@Size(max = 2000) String notes
|
||||||
|
) {}
|
||||||
@@ -28,6 +28,18 @@ import java.util.UUID;
|
|||||||
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
||||||
* types stay optional.
|
* types stay optional.
|
||||||
*
|
*
|
||||||
|
* <p><b>Letter→event link ({@code linkedEventId}):</b> for a {@link Kind#LETTER} entry, the id of
|
||||||
|
* the curated {@link TimelineEvent} whose {@code documents} set contains the letter's document, or
|
||||||
|
* {@code null} when the letter is referenced by no curated event (#850). Computed on read from the
|
||||||
|
* existing {@code timeline_event_documents} join — no new column. {@code null} for non-letter
|
||||||
|
* entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
||||||
|
* type stays optional.
|
||||||
|
*
|
||||||
|
* <p><b>Event description ({@code description}):</b> curator-authored context note for a curated
|
||||||
|
* {@link Kind#EVENT} entry (#844). Populated from {@link TimelineEvent#getDescription()} — null
|
||||||
|
* for {@link Kind#LETTER} and derived entries. Deliberately NOT
|
||||||
|
* {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript type stays optional.
|
||||||
|
*
|
||||||
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
|
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
|
||||||
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
|
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +59,8 @@ public record TimelineEntryDTO(
|
|||||||
DerivedEventType derivedType,
|
DerivedEventType derivedType,
|
||||||
UUID rootTagId,
|
UUID rootTagId,
|
||||||
String rootTagName,
|
String rootTagName,
|
||||||
String rootTagColor
|
String rootTagColor,
|
||||||
|
UUID linkedEventId,
|
||||||
|
String description
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ public class TimelineEventService {
|
|||||||
p.getBirthDate(), null,
|
p.getBirthDate(), null,
|
||||||
p.getDisplayName(), EventType.PERSONAL,
|
p.getDisplayName(), EventType.PERSONAL,
|
||||||
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
|
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
|
||||||
null, null, null))
|
null, null, null, null, null))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ public class TimelineEventService {
|
|||||||
p.getDeathDate(), null,
|
p.getDeathDate(), null,
|
||||||
p.getDisplayName(), EventType.PERSONAL,
|
p.getDisplayName(), EventType.PERSONAL,
|
||||||
null, null, List.of(p.getId()), DerivedEventType.DEATH,
|
null, null, List.of(p.getId()), DerivedEventType.DEATH,
|
||||||
null, null, null))
|
null, null, null, null, null))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,13 +290,11 @@ public class TimelineEventService {
|
|||||||
List<TimelineEntryDTO> result = new ArrayList<>();
|
List<TimelineEntryDTO> result = new ArrayList<>();
|
||||||
for (PersonRelationship r : spouseEdges) {
|
for (PersonRelationship r : spouseEdges) {
|
||||||
if (seen.add(r.getId())) {
|
if (seen.add(r.getId())) {
|
||||||
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded
|
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded.
|
||||||
LocalDate eventDate = r.getFromYear() != null
|
// The marriage date is the relationship's from_date at its stored precision
|
||||||
? LocalDate.of(r.getFromYear(), 1, 1)
|
// (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year.
|
||||||
: null;
|
LocalDate eventDate = r.getFromDate();
|
||||||
DatePrecision precision = r.getFromYear() != null
|
DatePrecision precision = r.getFromDatePrecision();
|
||||||
? DatePrecision.YEAR
|
|
||||||
: DatePrecision.UNKNOWN;
|
|
||||||
String title = r.getPerson().getDisplayName()
|
String title = r.getPerson().getDisplayName()
|
||||||
+ " & " + r.getRelatedPerson().getDisplayName();
|
+ " & " + r.getRelatedPerson().getDisplayName();
|
||||||
result.add(new TimelineEntryDTO(
|
result.add(new TimelineEntryDTO(
|
||||||
@@ -306,7 +304,7 @@ public class TimelineEventService {
|
|||||||
null, null,
|
null, null,
|
||||||
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
|
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
|
||||||
DerivedEventType.MARRIAGE,
|
DerivedEventType.MARRIAGE,
|
||||||
null, null, null));
|
null, null, null, null, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -80,13 +80,20 @@ public class TimelineService {
|
|||||||
// Resolve generation person IDs once — used across all three layers
|
// Resolve generation person IDs once — used across all three layers
|
||||||
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
||||||
|
|
||||||
|
// Fetch curated events once; the events that survive the filter below feed both the
|
||||||
|
// event entries and the batched letter→event link pass (resolveLetterEventLinks), so the
|
||||||
|
// membership pass costs no extra query and touches only on-screen events. REQ-009.
|
||||||
|
List<TimelineEvent> allEvents = eventRepository.findAll();
|
||||||
|
|
||||||
// ── curated events ───────────────────────────────────────────────────
|
// ── curated events ───────────────────────────────────────────────────
|
||||||
List<TimelineEntryDTO> entries = new ArrayList<>();
|
List<TimelineEntryDTO> entries = new ArrayList<>();
|
||||||
for (TimelineEvent ev : eventRepository.findAll()) {
|
List<TimelineEvent> filteredEvents = new ArrayList<>();
|
||||||
|
for (TimelineEvent ev : allEvents) {
|
||||||
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
||||||
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
||||||
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
||||||
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
|
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
|
||||||
|
filteredEvents.add(ev);
|
||||||
entries.add(mapEvent(ev));
|
entries.add(mapEvent(ev));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +114,9 @@ public class TimelineService {
|
|||||||
letters.add(doc);
|
letters.add(doc);
|
||||||
}
|
}
|
||||||
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
|
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
|
||||||
|
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, filteredEvents);
|
||||||
for (Document doc : letters) {
|
for (Document doc : letters) {
|
||||||
entries.add(mapDocument(doc, rootByDocId));
|
entries.add(mapDocument(doc, rootByDocId, eventByDocId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return bucket(entries);
|
return bucket(entries);
|
||||||
@@ -229,11 +237,14 @@ public class TimelineService {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null
|
null,
|
||||||
|
null,
|
||||||
|
ev.getDescription()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
|
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId,
|
||||||
|
Map<UUID, UUID> eventByDocId) {
|
||||||
RootTag root = rootByDocId.get(doc.getId());
|
RootTag root = rootByDocId.get(doc.getId());
|
||||||
return new TimelineEntryDTO(
|
return new TimelineEntryDTO(
|
||||||
Kind.LETTER,
|
Kind.LETTER,
|
||||||
@@ -251,10 +262,51 @@ public class TimelineService {
|
|||||||
null,
|
null,
|
||||||
root == null ? null : root.id(),
|
root == null ? null : root.id(),
|
||||||
root == null ? null : root.name(),
|
root == null ? null : root.name(),
|
||||||
root == null ? null : root.color()
|
root == null ? null : root.color(),
|
||||||
|
eventByDocId.get(doc.getId()),
|
||||||
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves each letter's linked curated event in one batched pass, keyed by document id: the
|
||||||
|
* event whose {@code documents} set contains the letter (REQ-009). A single doc→event map is
|
||||||
|
* built over the already-loaded events — no per-letter query ({@code TimelineEvent.documents}
|
||||||
|
* carries {@code @BatchSize(50)}). When a document is referenced by more than one curated
|
||||||
|
* event, the earliest-dated event wins (then lowest {@code eventId}); the pass runs over a
|
||||||
|
* stably-ordered copy so the link is a deterministic property of the data, not a coin-flip on
|
||||||
|
* the undefined repository iteration order ({@code putIfAbsent} keeps the first = winner). The
|
||||||
|
* map is built only over the events that survived the timeline filter, so the lazy
|
||||||
|
* {@code documents} collection is hydrated for on-screen events alone (finding #10); a letter
|
||||||
|
* whose only linking event was filtered out links to nothing, matching the frontend's
|
||||||
|
* filter-then-cluster (#850). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
|
||||||
|
*/
|
||||||
|
private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
|
||||||
|
Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
|
||||||
|
if (letterDocIds.isEmpty()) return Map.of();
|
||||||
|
|
||||||
|
// Stable order so a multi-event letter links deterministically: earliest event date
|
||||||
|
// (undated last), then lowest id. putIfAbsent below then keeps the winner (REQ-009).
|
||||||
|
List<TimelineEvent> ordered = events.stream()
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing(TimelineEvent::getEventDate,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||||
|
.thenComparing(TimelineEvent::getId))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Map<UUID, UUID> eventByDocId = new HashMap<>();
|
||||||
|
for (TimelineEvent ev : ordered) {
|
||||||
|
Set<Document> linkedDocs = ev.getDocuments();
|
||||||
|
if (linkedDocs == null) continue;
|
||||||
|
for (Document linked : linkedDocs) {
|
||||||
|
if (letterDocIds.contains(linked.getId())) {
|
||||||
|
eventByDocId.putIfAbsent(linked.getId(), ev.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventByDocId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
|
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
|
||||||
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
|
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- V78: person_relationships.from_year/to_year (integer) → from_date/to_date (date)
|
||||||
|
-- plus NOT NULL precision columns, mirroring persons.{birth,death}_date (V76 / ADR-039).
|
||||||
|
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-044).
|
||||||
|
-- One-way migration: rollback is a targeted pg_restore -t person_relationships from
|
||||||
|
-- the pre-deploy backup (see docs/DEPLOYMENT.md). The column drop is NOT
|
||||||
|
-- rolling-deploy-safe — stop the old JAR before running this migration.
|
||||||
|
|
||||||
|
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
|
||||||
|
-- before any DDL runs. Single-writer family archive, so no race window matters.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year)
|
||||||
|
THEN RAISE EXCEPTION 'V78 aborted: % relationships have from_year > to_year — fix data before migrating',
|
||||||
|
(SELECT COUNT(*) FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year);
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year = 0 OR to_year = 0)
|
||||||
|
THEN RAISE EXCEPTION 'V78 aborted: person_relationships table contains from_year=0 or to_year=0 rows — clean data before migrating';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN from_date date;
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN from_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN to_date date;
|
||||||
|
ALTER TABLE person_relationships ADD COLUMN to_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||||
|
|
||||||
|
UPDATE person_relationships SET from_date = make_date(from_year, 1, 1), from_date_precision = 'YEAR'
|
||||||
|
WHERE from_year IS NOT NULL;
|
||||||
|
UPDATE person_relationships SET to_date = make_date(to_year, 1, 1), to_date_precision = 'YEAR'
|
||||||
|
WHERE to_year IS NOT NULL;
|
||||||
|
|
||||||
|
-- Named constraints: readable Postgres error messages when violated.
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_coherence
|
||||||
|
CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_coherence
|
||||||
|
CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_date_order
|
||||||
|
CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date);
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_precision_values
|
||||||
|
CHECK (from_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||||
|
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_precision_values
|
||||||
|
CHECK (to_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||||
|
|
||||||
|
ALTER TABLE person_relationships DROP COLUMN from_year;
|
||||||
|
ALTER TABLE person_relationships DROP COLUMN to_year;
|
||||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
import org.mockito.InOrder;
|
import org.mockito.InOrder;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
@@ -169,7 +170,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
RelationshipDTO edge = new RelationshipDTO(
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
UUID.randomUUID(), parentId, childId,
|
UUID.randomUUID(), parentId, childId,
|
||||||
"Parent", null, null, "Child", null, null,
|
"Parent", null, null, "Child", null, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.getFamilyNetwork())
|
when(relationshipService.getFamilyNetwork())
|
||||||
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.person.PersonService;
|
|||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -76,7 +76,7 @@ class PersonTreeImporterTest {
|
|||||||
new PersonTreeImporter(personService, relationshipService)
|
new PersonTreeImporter(personService, relationshipService)
|
||||||
.load(json.toFile());
|
.load(json.toFile());
|
||||||
|
|
||||||
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
|
ArgumentCaptor<RelationshipUpsertRequest> captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class);
|
||||||
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
||||||
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
||||||
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
@@ -25,6 +26,8 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doNothing;
|
import static org.mockito.Mockito.doNothing;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
@@ -98,7 +101,7 @@ class RelationshipControllerTest {
|
|||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", 1900, 1980,
|
"Alice Müller", 1900, 1980,
|
||||||
"Bob Müller", 1930, null,
|
"Bob Müller", 1930, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.getFamilyNetwork())
|
when(relationshipService.getFamilyNetwork())
|
||||||
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ class RelationshipControllerTest {
|
|||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", null, null,
|
"Alice Müller", null, null,
|
||||||
"Bob Müller", null, null,
|
"Bob Müller", null, null,
|
||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
@@ -158,4 +161,51 @@ class RelationshipControllerTest {
|
|||||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PUT /api/persons/{id}/relationships/{relId} ──────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
RelationshipDTO updated = new RelationshipDTO(
|
||||||
|
relId, PERSON_ID, OTHER_ID,
|
||||||
|
"Alice Müller", null, null,
|
||||||
|
"Bob Müller", null, null,
|
||||||
|
RelationType.SPOUSE_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||||
|
when(relationshipService.updateRelationship(any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.relationType").value("SPOUSE_OF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
verify(relationshipService, never()).updateRelationship(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void updateRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void updateRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies V78: person_relationships.from_year/to_year (integer) become
|
||||||
|
* from_date/to_date (date) + *_date_precision columns, with backfill to
|
||||||
|
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
|
||||||
|
* pre-check that aborts the migration on corrupt year data. Mirrors
|
||||||
|
* {@code PersonBirthDeathMigrationTest} (V76 / ADR-039).
|
||||||
|
*
|
||||||
|
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
|
||||||
|
* database so the staged migrate-to-V77 → seed → migrate-to-latest flow and
|
||||||
|
* the abort cases cannot interfere with each other. Uses a real Postgres
|
||||||
|
* container — H2 does not honour CHECK constraints.
|
||||||
|
*/
|
||||||
|
class RelationshipMigrationTest {
|
||||||
|
|
||||||
|
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||||
|
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
|
||||||
|
|
||||||
|
private String dbUrl;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void startContainer() {
|
||||||
|
POSTGRES.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stopContainer() {
|
||||||
|
POSTGRES.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void createFreshDatabase() throws SQLException {
|
||||||
|
String dbName = "mig_v78_" + DB_COUNTER.incrementAndGet();
|
||||||
|
try (Connection conn = DriverManager.getConnection(
|
||||||
|
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||||
|
Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("CREATE DATABASE " + dbName);
|
||||||
|
}
|
||||||
|
dbUrl = baseUrl(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void precheck_abortsWhenFromYearAfterToYear() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1958, 1923);
|
||||||
|
|
||||||
|
assertThatThrownBy(this::migrateToLatest)
|
||||||
|
.hasMessageContaining("V78 aborted")
|
||||||
|
.hasMessageContaining("from_year > to_year");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void precheck_abortsWhenYearZeroPresent() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "FRIEND", 0, null);
|
||||||
|
|
||||||
|
assertThatThrownBy(this::migrateToLatest)
|
||||||
|
.hasMessageContaining("V78 aborted")
|
||||||
|
.hasMessageContaining("from_year=0 or to_year=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_fromYearAndToYear_becomeYearPrecisionDates() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
RelationDates row = relationDates(a, b, "SPOUSE_OF");
|
||||||
|
assertThat(row.fromDate()).hasToString("1923-01-01");
|
||||||
|
assertThat(row.fromPrecision()).isEqualTo("YEAR");
|
||||||
|
assertThat(row.toDate()).hasToString("1958-01-01");
|
||||||
|
assertThat(row.toPrecision()).isEqualTo("YEAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "FRIEND", null, null);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
RelationDates row = relationDates(a, b, "FRIEND");
|
||||||
|
assertThat(row.fromDate()).isNull();
|
||||||
|
assertThat(row.fromPrecision()).isEqualTo("UNKNOWN");
|
||||||
|
assertThat(row.toDate()).isNull();
|
||||||
|
assertThat(row.toPrecision()).isEqualTo("UNKNOWN");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_preservesRowCount() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
UUID c = seedPerson("Gamma");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
seedRelationship(a, c, "FRIEND", null, null);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThat(countWhere("1 = 1")).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void orderCheckConstraint_rejectsToDateBeforeFromDate() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> insertDatedRelationship(
|
||||||
|
a, b, "FRIEND", "1958-01-01", "YEAR", "1923-01-01", "YEAR"))
|
||||||
|
.hasMessageContaining("chk_relationship_date_order");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> insertDatedRelationship(
|
||||||
|
a, b, "FRIEND", "1923-01-01", "UNKNOWN", null, "UNKNOWN"))
|
||||||
|
.hasMessageContaining("chk_relationship_from_coherence");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
|
||||||
|
migrateTo("77");
|
||||||
|
UUID a = seedPerson("Alpha");
|
||||||
|
UUID b = seedPerson("Beta");
|
||||||
|
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
||||||
|
|
||||||
|
migrateToLatest();
|
||||||
|
|
||||||
|
assertThat(columnExists("from_year")).isFalse();
|
||||||
|
assertThat(columnExists("to_year")).isFalse();
|
||||||
|
assertThat(columnExists("from_date")).isTrue();
|
||||||
|
assertThat(columnExists("to_date")).isTrue();
|
||||||
|
for (String constraint : new String[]{
|
||||||
|
"chk_relationship_from_coherence",
|
||||||
|
"chk_relationship_to_coherence",
|
||||||
|
"chk_relationship_date_order",
|
||||||
|
"chk_relationship_from_precision_values",
|
||||||
|
"chk_relationship_to_precision_values"}) {
|
||||||
|
assertThat(constraintExists(constraint)).as(constraint).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private static String baseUrl(String dbName) {
|
||||||
|
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateTo(String targetVersion) {
|
||||||
|
flywayBuilder().target(targetVersion).load().migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateToLatest() {
|
||||||
|
flywayBuilder().load().migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
|
||||||
|
return Flyway.configure()
|
||||||
|
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.placeholders(Map.of("grafanaDbPassword", "test-only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID seedPerson(String lastName) throws SQLException {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO persons (id, last_name, person_type, family_member, provisional) "
|
||||||
|
+ "VALUES (?, ?, 'PERSON', false, false)")) {
|
||||||
|
stmt.setObject(1, id);
|
||||||
|
stmt.setString(2, lastName);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedRelationship(UUID personId, UUID relatedId, String type, Integer fromYear, Integer toYear)
|
||||||
|
throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO person_relationships (id, person_id, related_person_id, relation_type, from_year, to_year) "
|
||||||
|
+ "VALUES (gen_random_uuid(), ?, ?, ?, ?, ?)")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
stmt.setObject(4, fromYear);
|
||||||
|
stmt.setObject(5, toYear);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertDatedRelationship(UUID personId, UUID relatedId, String type,
|
||||||
|
String fromDate, String fromPrecision,
|
||||||
|
String toDate, String toPrecision) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO person_relationships "
|
||||||
|
+ "(id, person_id, related_person_id, relation_type, from_date, from_date_precision, to_date, to_date_precision) "
|
||||||
|
+ "VALUES (gen_random_uuid(), ?, ?, ?, CAST(? AS date), ?, CAST(? AS date), ?)")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
stmt.setObject(4, fromDate);
|
||||||
|
stmt.setString(5, fromPrecision);
|
||||||
|
stmt.setObject(6, toDate);
|
||||||
|
stmt.setString(7, toPrecision);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RelationDates(Object fromDate, String fromPrecision, Object toDate, String toPrecision) {}
|
||||||
|
|
||||||
|
private RelationDates relationDates(UUID personId, UUID relatedId, String type) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT from_date, from_date_precision, to_date, to_date_precision "
|
||||||
|
+ "FROM person_relationships WHERE person_id = ? AND related_person_id = ? AND relation_type = ?")) {
|
||||||
|
stmt.setObject(1, personId);
|
||||||
|
stmt.setObject(2, relatedId);
|
||||||
|
stmt.setString(3, type);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
assertThat(rs.next()).as("relationship exists").isTrue();
|
||||||
|
return new RelationDates(
|
||||||
|
rs.getObject("from_date"),
|
||||||
|
rs.getString("from_date_precision"),
|
||||||
|
rs.getObject("to_date"),
|
||||||
|
rs.getString("to_date_precision"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countWhere(String condition) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM person_relationships WHERE " + condition)) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean columnExists(String columnName) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
+ "WHERE table_schema = 'public' AND table_name = 'person_relationships' AND column_name = ?")) {
|
||||||
|
stmt.setString(1, columnName);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean constraintExists(String constraintName) throws SQLException {
|
||||||
|
try (Connection conn = connect();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
|
||||||
|
stmt.setString(1, constraintName);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Connection connect() throws SQLException {
|
||||||
|
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
||||||
@@ -20,6 +21,7 @@ import org.springframework.context.annotation.Import;
|
|||||||
|
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -65,13 +67,17 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_stores_and_is_readable() {
|
void addRelationship_stores_and_is_readable() {
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
||||||
|
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, null);
|
||||||
|
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThat(created.id()).isNotNull();
|
assertThat(created.id()).isNotNull();
|
||||||
assertThat(created.personId()).isEqualTo(alice.getId());
|
assertThat(created.personId()).isEqualTo(alice.getId());
|
||||||
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
assertThat(created.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||||
|
assertThat(created.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||||
|
assertThat(created.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
|
|
||||||
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
||||||
assertThat(rels).hasSize(1);
|
assertThat(rels).hasSize(1);
|
||||||
@@ -80,7 +86,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_409_when_duplicate() {
|
void addRelationship_throws_409_when_duplicate() {
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
relationshipService.addRelationship(alice.getId(), dto);
|
relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
||||||
@@ -93,9 +99,9 @@ class RelationshipServiceIntegrationTest {
|
|||||||
void addRelationship_throws_409_when_circular_parent() {
|
void addRelationship_throws_409_when_circular_parent() {
|
||||||
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
|
|
||||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -103,28 +109,58 @@ class RelationshipServiceIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
void deleteRelationship_throws_404_when_rel_belongs_to_different_person() {
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
|
|
||||||
// Charlie is unrelated to this row.
|
// Charlie is unrelated to this row. Ownership mismatch is 404, not 403, so a
|
||||||
|
// curator cannot enumerate relationship ids belonging to people they can't see
|
||||||
|
// (anti-enumeration; aligned with the PUT endpoint — ADR-044).
|
||||||
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.FORBIDDEN);
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
|
||||||
// The row is still there.
|
// The row is still there.
|
||||||
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_persists_new_type_dates_and_notes() {
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null));
|
||||||
|
|
||||||
|
RelationshipDTO updated = relationshipService.updateRelationship(alice.getId(), created.id(),
|
||||||
|
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
|
||||||
|
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day"));
|
||||||
|
|
||||||
|
assertThat(updated.id()).isEqualTo(created.id());
|
||||||
|
assertThat(updated.relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
assertThat(updated.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
||||||
|
assertThat(updated.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
assertThat(updated.notes()).isEqualTo("wedding day");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_404_when_rel_belongs_to_different_person() {
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> relationshipService.updateRelationship(charlie.getId(), created.id(),
|
||||||
|
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null)))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
||||||
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
||||||
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
||||||
|
|
||||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
|
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -135,7 +171,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
||||||
// alice SPOUSE_OF bob. Bob deletes from his side.
|
// alice SPOUSE_OF bob. Bob deletes from his side.
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
||||||
|
|
||||||
relationshipService.deleteRelationship(bob.getId(), created.id());
|
relationshipService.deleteRelationship(bob.getId(), created.id());
|
||||||
|
|
||||||
@@ -148,7 +184,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
||||||
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
relationshipService.setFamilyMember(charlie.getId(), false);
|
relationshipService.setFamilyMember(charlie.getId(), false);
|
||||||
|
|
||||||
NetworkDTO before = relationshipService.getFamilyNetwork();
|
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||||
@@ -165,7 +201,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void delete_person_cascades_to_relationships() {
|
void delete_person_cascades_to_relationships() {
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||||
UUID relId = created.id();
|
UUID relId = created.id();
|
||||||
assertThat(relationshipRepository.findById(relId)).isPresent();
|
assertThat(relationshipRepository.findById(relId)).isPresent();
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -59,9 +61,9 @@ class RelationshipServiceTest {
|
|||||||
charlie = person("Charlie");
|
charlie = person("Charlie");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Nora blocker 1 ---
|
// --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
|
||||||
@Test
|
@Test
|
||||||
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
|
void deleteRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
|
||||||
UUID relId = UUID.randomUUID();
|
UUID relId = UUID.randomUUID();
|
||||||
PersonRelationship rel = parentOf(alice, bob, relId);
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
@@ -69,7 +71,7 @@ class RelationshipServiceTest {
|
|||||||
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.FORBIDDEN);
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
verify(relationshipRepository, never()).delete(any());
|
verify(relationshipRepository, never()).delete(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@ class RelationshipServiceTest {
|
|||||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -98,7 +100,7 @@ class RelationshipServiceTest {
|
|||||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -107,7 +109,7 @@ class RelationshipServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
||||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
|
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -116,14 +118,42 @@ class RelationshipServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
|
void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
|
||||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
|
||||||
|
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
LocalDate.of(1950, 1, 1), DatePrecision.UNKNOWN, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
null, DatePrecision.DAY, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +170,16 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
||||||
|
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born");
|
||||||
var result = service.addRelationship(alice.getId(), dto);
|
var result = service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThat(result.personId()).isEqualTo(alice.getId());
|
assertThat(result.personId()).isEqualTo(alice.getId());
|
||||||
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||||
assertThat(result.fromYear()).isEqualTo(1900);
|
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||||
|
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||||
|
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
assertThat(result.notes()).isEqualTo("first born");
|
assertThat(result.notes()).isEqualTo("first born");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +199,7 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
service.addRelationship(alice.getId(), dto);
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
verify(personService).setFamilyMember(alice.getId(), true);
|
verify(personService).setFamilyMember(alice.getId(), true);
|
||||||
@@ -187,7 +220,7 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null);
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
service.addRelationship(alice.getId(), dto);
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
||||||
@@ -216,6 +249,131 @@ class RelationshipServiceTest {
|
|||||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- updateRelationship (REQ-004/006/007/008/009/010/013) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_NOT_FOUND_when_relId_unknown() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(charlie.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_VALIDATION_ERROR_on_self_relation() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
||||||
|
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
|
||||||
|
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_throws_DUPLICATE_when_db_constraint_violated() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_updates_fields_and_returns_dto() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
|
||||||
|
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day");
|
||||||
|
var result = service.updateRelationship(alice.getId(), relId, dto);
|
||||||
|
|
||||||
|
assertThat(result.relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
||||||
|
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
|
assertThat(result.notes()).isEqualTo("wedding day");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateRelationship_marks_both_endpoints_family_when_updated_to_family_type() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SIBLING_OF, null, null, null, null, null);
|
||||||
|
service.updateRelationship(alice.getId(), relId, dto);
|
||||||
|
|
||||||
|
verify(personService).setFamilyMember(alice.getId(), true);
|
||||||
|
verify(personService).setFamilyMember(bob.getId(), true);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
||||||
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
||||||
@@ -260,11 +418,15 @@ class RelationshipServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
||||||
|
return relOf(parent, child, RelationType.PARENT_OF, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship relOf(Person subject, Person object, RelationType type, UUID id) {
|
||||||
return PersonRelationship.builder()
|
return PersonRelationship.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.person(parent)
|
.person(subject)
|
||||||
.relatedPerson(child)
|
.relatedPerson(object)
|
||||||
.relationType(RelationType.PARENT_OF)
|
.relationType(type)
|
||||||
.createdAt(Instant.now())
|
.createdAt(Instant.now())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,12 +81,19 @@ class DerivedEventsAssemblyTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
|
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
|
||||||
|
return makeSpouseEdgeWithDate(a, b,
|
||||||
|
fromYear != null ? LocalDate.of(fromYear, 1, 1) : null,
|
||||||
|
fromYear != null ? DatePrecision.YEAR : DatePrecision.UNKNOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersonRelationship makeSpouseEdgeWithDate(Person a, Person b, LocalDate fromDate, DatePrecision precision) {
|
||||||
return PersonRelationship.builder()
|
return PersonRelationship.builder()
|
||||||
.id(UUID.randomUUID())
|
.id(UUID.randomUUID())
|
||||||
.person(a)
|
.person(a)
|
||||||
.relatedPerson(b)
|
.relatedPerson(b)
|
||||||
.relationType(RelationType.SPOUSE_OF)
|
.relationType(RelationType.SPOUSE_OF)
|
||||||
.fromYear(fromYear)
|
.fromDate(fromDate)
|
||||||
|
.fromDatePrecision(precision)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +230,24 @@ class DerivedEventsAssemblyTest {
|
|||||||
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
|
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- REQ-017 (#837): derived Heirat sources SPOUSE_OF.fromDate at its stored precision ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_emit_day_precision_heirat_from_spouse_fromDate() {
|
||||||
|
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
||||||
|
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
||||||
|
PersonRelationship edge = makeSpouseEdgeWithDate(anna, hans, LocalDate.of(1923, 5, 12), DatePrecision.DAY);
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
||||||
|
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
||||||
|
|
||||||
|
TimelineEntryDTO heirat = service.assembleDerivedEvents().stream()
|
||||||
|
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
|
||||||
|
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
||||||
|
assertThat(heirat.precision()).isEqualTo(DatePrecision.DAY);
|
||||||
|
}
|
||||||
|
|
||||||
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
|
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -74,6 +74,28 @@ class TimelineControllerTest {
|
|||||||
.andExpect(jsonPath("$.undated").isArray());
|
.andExpect(jsonPath("$.undated").isArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── REQ-001: description field serialised ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||||
|
void timelineIncludesEventDescription() throws Exception {
|
||||||
|
// REQ-001 (controller slice): a curated event entry with description "Kontext" is
|
||||||
|
// serialised into the timeline response at the correct JSON path.
|
||||||
|
var entry = new TimelineEntryDTO(Kind.EVENT, org.raddatz.familienarchiv.document.DatePrecision.DAY,
|
||||||
|
false, "", "",
|
||||||
|
java.time.LocalDate.of(1914, 8, 1), null, "Kriegsbeginn",
|
||||||
|
EventType.HISTORICAL, UUID.randomUUID(), null, List.of(), null,
|
||||||
|
null, null, null, null, "Kontext");
|
||||||
|
when(timelineService.assemble(any()))
|
||||||
|
.thenReturn(new TimelineDTO(
|
||||||
|
List.of(new TimelineYearDTO(1914, List.of(entry))),
|
||||||
|
List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/timeline"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.years[0].entries[0].description", is("Kontext")));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Parameter binding ────────────────────────────────────────────────────
|
// ─── Parameter binding ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ class TimelineServiceTest {
|
|||||||
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||||
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
||||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
|
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
|
||||||
null, null, null);
|
null, null, null, null, null);
|
||||||
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
||||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
|
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
|
||||||
null, null, null);
|
null, null, null, null, null);
|
||||||
|
|
||||||
var sorted = List.of(e2, e1).stream()
|
var sorted = List.of(e2, e1).stream()
|
||||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||||
@@ -511,6 +511,153 @@ class TimelineServiceTest {
|
|||||||
verify(tagService, times(1)).resolveRootTags(anyList());
|
verify(tagService, times(1)).resolveRootTags(anyList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── event description (#844, REQ-001) ───────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapEvent_populates_description_from_event() {
|
||||||
|
// REQ-001: a curated event with a description surfaces it on the assembled entry.
|
||||||
|
TimelineEvent ev = TimelineEvent.builder().id(UUID.randomUUID())
|
||||||
|
.title("Kriegsbeginn").type(EventType.HISTORICAL)
|
||||||
|
.eventDate(LocalDate.of(1914, 8, 1)).precision(DatePrecision.DAY)
|
||||||
|
.description("Kontext")
|
||||||
|
.build();
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(ev));
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||||
|
|
||||||
|
TimelineDTO result = timelineService.assemble(noFilters());
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = result.years().get(0).entries().get(0);
|
||||||
|
assertThat(entry.description()).isEqualTo("Kontext");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapEvent_leaves_description_null_when_event_has_none() {
|
||||||
|
// REQ-001: an event without a description → null on the entry.
|
||||||
|
TimelineEvent ev = TimelineEvent.builder().id(UUID.randomUUID())
|
||||||
|
.title("Ereignis").type(EventType.PERSONAL)
|
||||||
|
.eventDate(LocalDate.of(1920, 1, 1)).precision(DatePrecision.YEAR)
|
||||||
|
.build();
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(ev));
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = timelineService.assemble(noFilters()).years().get(0).entries().get(0);
|
||||||
|
assertThat(entry.description()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapDocument_leaves_description_null_for_letter() {
|
||||||
|
// REQ-001: LETTER entries carry null description, regardless of any document fields.
|
||||||
|
Document doc = docWithDate(LocalDate.of(1916, 3, 1), DatePrecision.MONTH);
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of());
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||||
|
assertThat(entry.description()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── letter→event link (#850, REQ-009) ───────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letter_in_a_curated_events_documents_carries_that_events_id() {
|
||||||
|
// REQ-009: linkedEventId = the curated event whose documents set contains the letter.
|
||||||
|
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
|
||||||
|
UUID eventId = UUID.randomUUID();
|
||||||
|
TimelineEvent event = TimelineEvent.builder().id(eventId)
|
||||||
|
.title("Briefe von der Front").type(EventType.PERSONAL)
|
||||||
|
.documents(new HashSet<>(Set.of(letterDoc)))
|
||||||
|
.build(); // no eventDate → event lands undated, leaving the year band to the letter
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(event));
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||||
|
|
||||||
|
assertThat(entry.linkedEventId()).isEqualTo(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letter_in_no_curated_event_has_null_linkedEventId() {
|
||||||
|
// REQ-009: a letter referenced by no curated event → linkedEventId null; the frontend
|
||||||
|
// then renders it as a loose chronological letter (REQ-006).
|
||||||
|
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
|
||||||
|
TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID())
|
||||||
|
.title("Anderes Ereignis").type(EventType.PERSONAL)
|
||||||
|
.documents(new HashSet<>(Set.of(Document.builder().id(UUID.randomUUID()).build())))
|
||||||
|
.build();
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(event));
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||||
|
|
||||||
|
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||||
|
|
||||||
|
assertThat(entry.linkedEventId()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multi_event_letter_links_deterministically_to_the_earliest_event() {
|
||||||
|
// REQ-009: a document referenced by >1 curated event links to the earliest-dated event
|
||||||
|
// (then lowest id), independent of repository iteration order — not a coin-flip on
|
||||||
|
// findAll()'s undefined order.
|
||||||
|
Document shared = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
|
||||||
|
TimelineEvent earlier = TimelineEvent.builder()
|
||||||
|
.id(UUID.fromString("00000000-0000-0000-0000-000000000001"))
|
||||||
|
.title("Frühes Ereignis").type(EventType.PERSONAL)
|
||||||
|
.eventDate(LocalDate.of(1913, 3, 1)).precision(DatePrecision.MONTH)
|
||||||
|
.documents(new HashSet<>(Set.of(shared)))
|
||||||
|
.build();
|
||||||
|
TimelineEvent later = TimelineEvent.builder()
|
||||||
|
.id(UUID.fromString("00000000-0000-0000-0000-000000000002"))
|
||||||
|
.title("Spätes Ereignis").type(EventType.PERSONAL)
|
||||||
|
.eventDate(LocalDate.of(1914, 3, 1)).precision(DatePrecision.MONTH)
|
||||||
|
.documents(new HashSet<>(Set.of(shared)))
|
||||||
|
.build();
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(shared));
|
||||||
|
|
||||||
|
// `later` first in iteration: a naive putIfAbsent would wrongly pick it.
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(later, earlier));
|
||||||
|
assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
|
||||||
|
.isEqualTo(earlier.getId());
|
||||||
|
|
||||||
|
// Reversed order yields the same winner — the link is order-independent.
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(earlier, later));
|
||||||
|
assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
|
||||||
|
.isEqualTo(earlier.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letter_linked_only_to_a_filtered_out_event_has_null_linkedEventId() {
|
||||||
|
// finding #10: the link pass runs over the events that survived the filter, not all of
|
||||||
|
// them. A letter whose only linking event is excluded by the active filter links to
|
||||||
|
// nothing (the frontend would drop it loose anyway) — and the lazy `documents` collection
|
||||||
|
// is never hydrated for events that are off-screen.
|
||||||
|
Document letterDoc = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
|
||||||
|
TimelineEvent worldEvent = TimelineEvent.builder().id(UUID.randomUUID())
|
||||||
|
.title("Somme").type(EventType.HISTORICAL)
|
||||||
|
.documents(new HashSet<>(Set.of(letterDoc)))
|
||||||
|
.build();
|
||||||
|
when(eventRepository.findAll()).thenReturn(List.of(worldEvent));
|
||||||
|
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||||
|
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||||
|
|
||||||
|
// Filter to PERSONAL only → the HISTORICAL event is filtered out of the view.
|
||||||
|
TimelineEntryDTO entry = theLetter(timelineService.assemble(
|
||||||
|
new TimelineFilter(null, null, EventType.PERSONAL, null, null)));
|
||||||
|
|
||||||
|
assertThat(entry.linkedEventId()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimelineEntryDTO theLetter(TimelineDTO result) {
|
||||||
|
return java.util.stream.Stream.concat(
|
||||||
|
result.years().stream().flatMap(y -> y.entries().stream()),
|
||||||
|
result.undated().stream())
|
||||||
|
.filter(e -> e.kind() == Kind.LETTER)
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
|
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
|
||||||
assertThat(result.years()).hasSize(1);
|
assertThat(result.years()).hasSize(1);
|
||||||
return result.years().get(0).entries().get(0);
|
return result.years().get(0).entries().get(0);
|
||||||
@@ -523,7 +670,7 @@ class TimelineServiceTest {
|
|||||||
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
|
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
|
||||||
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
|
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
|
||||||
date, null, title, null, null, UUID.randomUUID(), List.of(), null,
|
date, null, title, null, null, UUID.randomUUID(), List.of(), null,
|
||||||
null, null, null);
|
null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
||||||
|
|||||||
210
design_handoff_familienarchiv_redesign/DESIGN_RULES.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Familienarchiv — Design Rules (binding)
|
||||||
|
|
||||||
|
This is the visual law for the redesign. It promotes the informal `Regeln` page into
|
||||||
|
enforceable specs. **When this doc and a prototype disagree, the prototype wins** — these
|
||||||
|
values are transcribed from the prototypes, but the rendered file is ground truth.
|
||||||
|
|
||||||
|
Built on the **De Gruyter Brill** corporate identity. Tone: restrained, archival,
|
||||||
|
formal-respectful — institutional, not cute. German-first, formal **Sie**, never emoji.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design tokens
|
||||||
|
|
||||||
|
Port `prototypes/colors_and_type.css` verbatim into the app (Tailwind 4 `@theme` or CSS
|
||||||
|
custom properties). Components reference **semantic** tokens, never raw hex.
|
||||||
|
|
||||||
|
### Color — light mode
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--c-canvas` | `#f0efe9` | Page background (warm sand). Flat, no gradients. |
|
||||||
|
| `--c-surface` | `#ffffff` | Cards, inputs, menus. |
|
||||||
|
| `--c-muted` | `#f5f4ef` | Subtle fills (mission-control tiles, hover wash). |
|
||||||
|
| `--c-line` | `#e4e2d7` | 1px borders. |
|
||||||
|
| `--c-line-2` | `#eeede8` | Inner dividers / row separators. |
|
||||||
|
| `--c-ink` | `#012851` | Primary text (navy). |
|
||||||
|
| `--c-ink-2` | `#4b5563` | Secondary / body text. |
|
||||||
|
| `--c-ink-3` | `#6b7280` | Meta, placeholder, captions. |
|
||||||
|
| `--c-primary` | `#012851` | Primary buttons, active segment, header. |
|
||||||
|
| `--c-primary-fg` | `#ffffff` | Text on primary. |
|
||||||
|
| `--c-accent` | `#a1dcd8` | **Mint — decorative only.** Top stripes, left rules, underlines, timeline spine. **Never carries text.** |
|
||||||
|
| `--c-accent-bg` | `rgba(161,220,216,.15)` | Tinted note/skill backgrounds. |
|
||||||
|
| `--c-header` | `#012851` | Header bar (always navy, both themes). |
|
||||||
|
| `--c-turquoise` | `#00c7b1` | Transcription mode only. |
|
||||||
|
| `--c-danger` | `#c0392b` | Destructive actions (Löschen). |
|
||||||
|
| `--c-focus-ring` | `#012851` | 2px focus outline, 2px offset (mint in dark). |
|
||||||
|
|
||||||
|
### Color — dark mode (`:root[data-theme='dark']`)
|
||||||
|
|
||||||
|
Navy-tinted. Key flips: `--c-canvas:#010e1e`, `--c-surface:#011526`, `--c-muted:#011a30`,
|
||||||
|
`--c-line:#0d3358`, `--c-ink:#f0efe9`, `--c-ink-2:#9ca3af`, `--c-ink-3:#8b97a5`.
|
||||||
|
**`--c-accent` flips to turquoise `#00c7b1`**, and `--c-primary` flips to mint `#a1dcd8`
|
||||||
|
with `--c-primary-fg:#012851`. Full set in `colors_and_type.css`.
|
||||||
|
|
||||||
|
### Person / avatar palette (deterministic — see §5)
|
||||||
|
|
||||||
|
`#5a8a6a #a0522d #c17a00 #607080 #7a4f9a #c0446e #3060b0 #4a7a3a #9a8040 #c05540`
|
||||||
|
|
||||||
|
> The live avatar constant is `$lib/shared/avatarPalette.ts` (single source of
|
||||||
|
> truth). Three of these hues fail the ≥4.5:1 white-initials contrast floor and
|
||||||
|
> ship as AA-darkened variants there (sage `#527e61`, amber `#a46800`,
|
||||||
|
> sand `#897239`); the bright hues above remain the decorative tag-dot colors.
|
||||||
|
|
||||||
|
### Tag dot colors
|
||||||
|
|
||||||
|
`--c-tag-sage #5a8a6a`, `--c-tag-sienna #a0522d`, `--c-tag-amber #c17a00`,
|
||||||
|
`--c-tag-slate #607080`, `--c-tag-violet #7a4f9a`, `--c-tag-rose #c0446e`,
|
||||||
|
`--c-tag-cobalt #3060b0`, `--c-tag-moss #4a7a3a`, `--c-tag-sand #9a8040`,
|
||||||
|
`--c-tag-coral #c05540`.
|
||||||
|
|
||||||
|
### Badge types (Personen)
|
||||||
|
|
||||||
|
| Type | bg / text / border |
|
||||||
|
|---|---|
|
||||||
|
| Institution | `#e8eff7` / `#1a4971` / `#c4d5e8` |
|
||||||
|
| Gruppe | `#f0e8f5` / `#5a2d6f` / `#d8c5e3` |
|
||||||
|
| Unbekannt | `#fdf4e3` / `#7a5a0a` / `#f0ddb3` |
|
||||||
|
|
||||||
|
### Radius / shadow
|
||||||
|
|
||||||
|
- `--radius-sm: 2px` — cards, inputs, buttons, segmented control. **The default.**
|
||||||
|
- `--radius-md: 4px` — tag chips/badges only.
|
||||||
|
- `--radius-full: 9999px` — avatars, dots, pills.
|
||||||
|
- `--shadow-sm: 0 1px 2px 0 rgb(0 0 0/.05)` — resting cards.
|
||||||
|
- `--shadow-md: 0 4px 6px -1px rgb(0 0 0/.1), 0 2px 4px -2px rgb(0 0 0/.1)` — dropdowns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Typography
|
||||||
|
|
||||||
|
Two families. **Montserrat** (`--font-sans`) for all UI chrome; **Tinos** (`--font-serif`)
|
||||||
|
for headlines, body, letter content, transcriptions, story prose.
|
||||||
|
|
||||||
|
| Role | Family | Size / weight | Treatment |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Page title (`h1`) | Tinos | **46px / 700**, line-height 1.06 | sentence case |
|
||||||
|
| Story detail title | Tinos | 38px / 700, lh 1.15 | sentence case |
|
||||||
|
| Card title (`h3`) | Tinos | 19–24px / 700 | sentence case |
|
||||||
|
| Body / letter / snippet | Tinos | 15–18px, lh 1.55–1.75 | snippets *italic*, quotes use `„…“` |
|
||||||
|
| **Rubric / eyebrow label** | Montserrat | **12px / 700**, `letter-spacing:.14em`, UPPERCASE | above every page title |
|
||||||
|
| Section caption | Montserrat | 11–12px / 700, `.12–.14em`, UPPERCASE | card headers |
|
||||||
|
| Button / nav label | Montserrat | 11–12px / 700, `.08–.1em`, UPPERCASE | |
|
||||||
|
| Tag chip label | Montserrat | 10px / 700, `.13–.15em`, UPPERCASE | |
|
||||||
|
| Meta line | Montserrat | 12px / 400 | counts, dates; separated by ` · ` |
|
||||||
|
|
||||||
|
**Casing law:** UI chrome (labels, buttons, nav, captions, tags) is ALL CAPS + wide
|
||||||
|
tracking, Montserrat bold. Headlines and document titles are sentence case in Tinos serif.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Shared component: page header (eyebrow + title)
|
||||||
|
|
||||||
|
Every top-level page opens with this block. **Build it once** as a `PageHeader`
|
||||||
|
component (props: eyebrow, title, lede, optional right-side count or action).
|
||||||
|
|
||||||
|
```
|
||||||
|
<div border-left:4px solid var(--c-accent); padding-left:18px>
|
||||||
|
<eyebrow> Montserrat 12px/700, .14em, UPPERCASE, color --c-ink-3, margin-bottom 8px
|
||||||
|
<h1> Tinos 46px/700, lh 1.06, color --c-ink
|
||||||
|
<lede> Tinos italic 16px, color --c-ink-2, margin-top 10px, max-width 520px
|
||||||
|
```
|
||||||
|
|
||||||
|
The **4px mint left rule** is the signature. A right-aligned count
|
||||||
|
(`38 Personen`, `147 Dokumente · 38 Personen`) or a primary action button sits opposite via
|
||||||
|
`justify-content:space-between; align-items:flex-end`.
|
||||||
|
|
||||||
|
Page shell for every screen: `min-height:100vh; background:var(--c-canvas)`, header on top,
|
||||||
|
then `<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Shared component: app header + nav (`ArchiveHeader`)
|
||||||
|
|
||||||
|
Single sticky header reused on every page. **This is the most important thing to extract
|
||||||
|
into one component** — it is currently duplicated and must not be.
|
||||||
|
|
||||||
|
- `position:sticky; top:0; z-index:50; background:var(--c-header)`.
|
||||||
|
- **4px mint stripe** (`#a1dcd8`) across the very top, above the bar.
|
||||||
|
- Bar: `max-width:1180px; margin:0 auto; padding:0 32px; height:64px; display:flex; align-items:center`, white text.
|
||||||
|
- Wordmark `FAMILIENARCHIV`: Montserrat 18px/700, `letter-spacing:.16em`, UPPERCASE, `margin-right:28px`.
|
||||||
|
- Nav items: Montserrat 11px/700, `.07em`, UPPERCASE, color `rgba(255,255,255,.6)`,
|
||||||
|
`line-height:44px`. **Active** item → color `#fff` + `border-bottom:2px solid #a1dcd8`.
|
||||||
|
Nav order: Dokumente · Personen · Briefwechsel · Geschichten · Zeitstrahl · Aktivitäten
|
||||||
|
(· Regeln, internal). Each links to its route; pass the active key as a prop.
|
||||||
|
- Right cluster: **Hell / Dunkel** theme toggle (segmented; active segment = mint bg
|
||||||
|
`#a1dcd8` + navy text) and a round user avatar chip (`MR`, white bg, navy text, 32px).
|
||||||
|
- **Theme toggle** writes `localStorage['theme']` (`'light'|'dark'`) and sets
|
||||||
|
`document.documentElement.dataset.theme`. A tiny inline boot script in `<head>` reads it
|
||||||
|
before paint to avoid a flash. Map this to the app's existing theme mechanism if one
|
||||||
|
exists; otherwise replicate.
|
||||||
|
- Motion: `transition-colors` only. No transforms, no scale on press.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Shared primitive: avatar + deterministic color
|
||||||
|
|
||||||
|
Every person is a round avatar with initials, colored by a hash of the name so the same
|
||||||
|
person is always the same color across every screen. **Extract this into one util +
|
||||||
|
component** — it is currently copy-pasted into 6 files.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// palette = the 10 person colors in §1
|
||||||
|
function avatarFor(name) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length > 1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
return { bg: palette[h % palette.length], initials };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Always a perfect circle (`border-radius:full`), white initials in Montserrat 700, `line-height:1`.
|
||||||
|
- Sizes in use: **48px**/16px (selectors, person cards), **40px**/14px (story byline, feed,
|
||||||
|
Briefwechsel rows), **26px**/10px (overlapping stacks — `margin-left:-6px` +
|
||||||
|
`2px solid var(--c-surface)` ring), **28px** (timeline person nodes).
|
||||||
|
- The color **distinguishes, it does not decorate** — never restyle it for emphasis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Shared component: segmented control (filters / views)
|
||||||
|
|
||||||
|
Inline filter switch used on Personen, Geschichten, Zeitstrahl, Aktivitäten, and the header
|
||||||
|
theme toggle.
|
||||||
|
|
||||||
|
- `display:inline-flex; border:1px solid var(--c-line)` (radius 0 / sm; segments share borders).
|
||||||
|
- Each segment: Montserrat 12px/700, `.08em`, UPPERCASE, `padding:9–10px 16px`, `cursor:pointer`.
|
||||||
|
- **Active** segment: `background:var(--c-primary); color:var(--c-primary-fg)`.
|
||||||
|
- Inactive: `background:var(--c-surface); color:var(--c-ink-2)`, `border-left:1px solid var(--c-line)` between segments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cards, metadata, empty states
|
||||||
|
|
||||||
|
**Card:** `background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:20–24px`.
|
||||||
|
Most content cards add a **3px mint top border** (`border-top:3px solid var(--c-accent)`) as
|
||||||
|
the archival signature; some use a **3px mint left border** for inline/resume strips.
|
||||||
|
|
||||||
|
**Metadata line:** one Montserrat 12px line, items separated by ` · `, often led by a 14px
|
||||||
|
De Gruyter icon at `opacity:.5`. Example: `📅 14. März 1923 · 14 Dokumente · 4 Personen`
|
||||||
|
(icon is an `<img>`, not emoji).
|
||||||
|
|
||||||
|
**Status dots:** 7px circle + UPPERCASE label. Transkribiert/Veröffentlicht `#5a8a6a`,
|
||||||
|
In Arbeit `#c17a00`, Neu/Entwurf `#607080`.
|
||||||
|
|
||||||
|
**Empty state:** dashed `1px var(--c-line)` border, centered. Serif heading
|
||||||
|
(`Noch keine Geschichten angelegt.`) + Montserrat sub line ending in the German ellipsis
|
||||||
|
(`Beginnen Sie mit einem Brief…`). Quiet, Sie-form, helpful — never cute.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Motion, hover, imagery
|
||||||
|
|
||||||
|
- All motion is **color**, `transition: color/background/border .15–.2s`. No transform, no
|
||||||
|
scale, no press-down.
|
||||||
|
- Rows: `hover:bg-muted/50`. Links: 2px mint underline at `text-underline-offset:3px`,
|
||||||
|
`text-decoration-thickness:2px`. Header nav: `white/60 → white`.
|
||||||
|
- Backdrops `bg-black/20`. **No backdrop-blur, no glassmorphism, no gradients, no noise.**
|
||||||
|
- Imagery is warm aged letter scans; UI chrome stays cool navy to contrast.
|
||||||
|
- De Gruyter icons: black strokes as `<img>`, `opacity-40` resting tint (`.65` on the dark
|
||||||
|
header), globally inverted in dark mode.
|
||||||
217
design_handoff_familienarchiv_redesign/EPIC.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# EPIC: Familienarchiv Visual Redesign ("Mappe")
|
||||||
|
|
||||||
|
> Implement **in order**. Stories 1–4 are foundation and shared primitives — every screen
|
||||||
|
> depends on them. Do not start a screen story until 1–4 are merged, or the header, avatar,
|
||||||
|
> and tokens get re-implemented per screen and drift. Read `DESIGN_RULES.md` first; open the
|
||||||
|
> matching `prototypes/*.dc.html` for pixel ground truth while building each story.
|
||||||
|
|
||||||
|
## Epic goal
|
||||||
|
|
||||||
|
Reskin the entire Familienarchiv app to the unified "Mappe" archival direction and ship
|
||||||
|
three new sections (Geschichten, Zeitstrahl, Aktivitäten). All copy German-first via
|
||||||
|
Paraglide; light + dark mode; De Gruyter icon convention preserved.
|
||||||
|
|
||||||
|
## Epic-level acceptance criteria
|
||||||
|
|
||||||
|
- [ ] All eight screens match their prototype in light **and** dark mode.
|
||||||
|
- [ ] Header, page-header, avatar, segmented control, and card exist as **single shared
|
||||||
|
components** — zero duplication of the header markup or the avatar-color function.
|
||||||
|
- [ ] No raw hex in components; everything references the semantic tokens.
|
||||||
|
- [ ] Every visible string is a Paraglide message key, German authored first; `en`/`es`
|
||||||
|
stubs added.
|
||||||
|
- [ ] Icons rendered as `<img>`, invert correctly in dark mode.
|
||||||
|
- [ ] No gradients, blur, emoji, or transform-based motion introduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Reframe (2026-06-16): this is alignment, not greenfield.** An audit of the live codebase
|
||||||
|
> found the substrate already in place — the full token system (`DESIGN_RULES §1–2`), dark
|
||||||
|
> mode, the app header, and the three "new" sections (Geschichten, Zeitstrahl, Aktivitäten)
|
||||||
|
> all already exist. So **Stories 1–4 below are close-out + extraction**, not from-scratch
|
||||||
|
> builds, and Stories 5–11 are "align the existing screen to its prototype," not new pages.
|
||||||
|
> The detailed, trackable breakdown lives in the Gitea milestone **"Mappe Visual Redesign"**
|
||||||
|
> (issues split into *shared components* then *pages*); **Phase B** at the bottom of this file
|
||||||
|
> lists the screens that previously had no prototype and now do.
|
||||||
|
|
||||||
|
## Story 1 — Foundation: tokens, fonts, theme
|
||||||
|
|
||||||
|
**Goal:** establish the visual substrate the whole app reads from.
|
||||||
|
|
||||||
|
- Port `prototypes/colors_and_type.css` into the app's token layer (Tailwind 4 `@theme` /
|
||||||
|
`layout.css`). Keep every variable name in `DESIGN_RULES.md §1`.
|
||||||
|
- Wire Montserrat + Tinos (or licensed Gotham/Times) and the `--font-sans`/`--font-serif` vars.
|
||||||
|
- Implement light/dark via `:root[data-theme='dark']` + the pre-paint boot script that reads
|
||||||
|
`localStorage['theme']`. Add the global `img[src*='degruyter-icons']{filter:invert(1)}`
|
||||||
|
dark rule.
|
||||||
|
|
||||||
|
**Done when:** a throwaway page using `var(--c-*)` tokens renders correct in both themes; no
|
||||||
|
flash of wrong theme on reload.
|
||||||
|
|
||||||
|
## Story 2 — Shared: app header + nav (`ArchiveHeader`)
|
||||||
|
|
||||||
|
**Spec:** `DESIGN_RULES.md §4`. Prototype: `ArchiveHeader.dc.html`.
|
||||||
|
|
||||||
|
- One sticky header component: mint stripe, wordmark, nav with active-key prop, theme
|
||||||
|
toggle, user chip. Nav routes to all sections.
|
||||||
|
- Theme toggle drives the Story 1 mechanism.
|
||||||
|
|
||||||
|
**Done when:** header renders identically on every route; active item shows the mint
|
||||||
|
underline; toggle flips theme and persists.
|
||||||
|
|
||||||
|
## Story 3 — Shared: avatar + deterministic color
|
||||||
|
|
||||||
|
**Spec:** `DESIGN_RULES.md §5`.
|
||||||
|
|
||||||
|
- `avatarFor(name)` util (hash → palette index + initials) + an `<Avatar name size>`
|
||||||
|
component supporting 26/28/40/48px and the overlapping-stack ring variant.
|
||||||
|
|
||||||
|
**Done when:** the same name yields the same color everywhere; stacks overlap with the
|
||||||
|
surface-colored ring.
|
||||||
|
|
||||||
|
## Story 4 — Shared: page-header, segmented control, card, metadata, empty state
|
||||||
|
|
||||||
|
**Spec:** `DESIGN_RULES.md §3, §6, §7`.
|
||||||
|
|
||||||
|
- `PageHeader` (eyebrow + 4px mint left rule + serif h1 + italic lede + right slot).
|
||||||
|
- `SegmentedControl` (active = navy). `Card` (mint top/left border variants). `MetaLine`
|
||||||
|
(` · ` separated, optional leading icon). `EmptyState` (dashed, serif + ellipsis).
|
||||||
|
|
||||||
|
**Done when:** each primitive matches the prototype and is consumed by the screen stories
|
||||||
|
below — not re-styled inline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Story 5 — Dokumente (dashboard / search results)
|
||||||
|
|
||||||
|
**Route:** `/`. **Prototype:** `Dokumente.dc.html`.
|
||||||
|
|
||||||
|
PageHeader (eyebrow "Archiv", title "Dokumente", lede, right count "147 Dokumente · 38
|
||||||
|
Personen"). Then: a **search bar card** (input `Titel, Personen, Tags durchsuchen…` +
|
||||||
|
"Datum ↓" sort + "Filter" buttons); a **resume strip** (mint left border, "Weiter bei:" +
|
||||||
|
italic underlined doc link); a **`1fr 320px` grid** — left: "Zuletzt hinzugefügt" list
|
||||||
|
(title · avatar stack · right-aligned date `width:128px` · status dot+label `width:118px`),
|
||||||
|
right column: **upload dropzone** (dashed, Upload icon, `PDF, JPG, PNG, TIFF bis 50 MB`) +
|
||||||
|
"Benötigt Metadaten" card; below full-width **Mission Control** — 3 tiles (Segmentierung /
|
||||||
|
Transkription / Zur Überprüfung), each with a caption, a pill "skill" hint, a weekly count,
|
||||||
|
and a list of linked items. Right column collapses below `lg`; main goes full-width.
|
||||||
|
|
||||||
|
**Done when:** matches prototype both themes; status dots use the §7 colors; grid collapses.
|
||||||
|
|
||||||
|
## Story 6 — Personen (directory)
|
||||||
|
|
||||||
|
**Route:** `/persons`. **Prototype:** `Personen.dc.html`.
|
||||||
|
|
||||||
|
PageHeader (eyebrow "Verzeichnis") + right count "38 Personen". Search input
|
||||||
|
(`z.B. Oma Frieda, Onkel Karl…`) + segmented control (Alle / Personen / Institutionen /
|
||||||
|
Gruppen). **3-column card grid**; each card: 48px avatar, serif name, relation sub, optional
|
||||||
|
type **badge** (Institution/Gruppe/Unbekannt — §1 colors), divider, meta line
|
||||||
|
`✉ N Briefe · N Dokumente`. Cards carry the 3px mint top border.
|
||||||
|
|
||||||
|
**Done when:** badge colors correct; avatar colors deterministic; grid responsive.
|
||||||
|
|
||||||
|
## Story 7 — Briefwechsel — DROPPED
|
||||||
|
|
||||||
|
The two-person letter-exchange feature was removed from the product. Its prototype, route, and
|
||||||
|
nav entry no longer exist. Skip.
|
||||||
|
|
||||||
|
## Story 8 — Geschichten (story collections list) — NEW
|
||||||
|
|
||||||
|
**Route:** `/geschichten`. **Prototype:** `Geschichten.dc.html`.
|
||||||
|
|
||||||
|
PageHeader (eyebrow "Sammlungen") + primary button "Neue Geschichte". Segmented control
|
||||||
|
(Alle / Veröffentlicht / In Arbeit / Entwurf) + Filter button. **2-column card grid**; each
|
||||||
|
card (link, mint top border): tag chips (dot + UPPERCASE label), serif 24px title, serif dek,
|
||||||
|
meta line `📅 range · N Dokumente · N Personen`, footer with overlapping avatar stack + status
|
||||||
|
dot/label. Cards link to the story detail.
|
||||||
|
|
||||||
|
**Done when:** tag dots and status colors correct; cards link to Story 9.
|
||||||
|
|
||||||
|
## Story 9 — Geschichte (single story detail) — NEW
|
||||||
|
|
||||||
|
**Route:** `/geschichten/:id`. **Prototype:** `Geschichte.dc.html`. **Two variants**
|
||||||
|
(`variant` prop): **"Lesereise"** (a guided reading — intro + narration blocks + letter
|
||||||
|
cards + annotation notes) and **"Sammlung"** (a collection — intro + "Erwähnte Dokumente"
|
||||||
|
list). Centered `max-width:880px` article card (mint top border, `padding:48px 56px`):
|
||||||
|
type badge, 38px serif title, byline row (author avatar + name + "zusammengestellt am …" +
|
||||||
|
Bearbeiten / Löschen actions), intro paragraph, then ordered **blocks**:
|
||||||
|
|
||||||
|
- **narration** — 3px mint left rule, serif italic 18px.
|
||||||
|
- **letter** — clickable row: 40px tile w/ Mail icon, serif title, meta `date · von X an Y`,
|
||||||
|
trailing Arrow-Right icon.
|
||||||
|
- **note** — mint-tinted (`--c-accent-bg`) left-rule box, "Anmerkung" caption + serif italic.
|
||||||
|
|
||||||
|
**Done when:** both variants render from the prop; block types styled per spec; Löschen uses
|
||||||
|
`--c-danger`.
|
||||||
|
|
||||||
|
## Story 10 — Zeitstrahl (timeline) — NEW
|
||||||
|
|
||||||
|
**Route:** `/zeitstrahl`. **Prototype:** `Zeitstrahl.dc.html`.
|
||||||
|
|
||||||
|
PageHeader (eyebrow "Chronik") + right count. Segmented control (Alle / Briefe / Personen /
|
||||||
|
Ereignisse) + a small legend. **Centered vertical spine** (`max-width:760px`, 2px mint center
|
||||||
|
line). Item types stacked on the spine: **year** pill (navy), **summary** card (count + a
|
||||||
|
12-bar monthly-density mini chart in mint + range labels), **letter** cards **alternating
|
||||||
|
left/right** with a spine dot (`2px solid --c-primary`) and optional tag pill, **person**
|
||||||
|
node (28px navy circle glyph + name + derived meta), **curated** node (★, mint left rule),
|
||||||
|
**historical** band (full-width, Globe icon, serif italic, top/bottom hairline).
|
||||||
|
|
||||||
|
**Done when:** spine centered; letters alternate; bars scale to value %; all five item types
|
||||||
|
render.
|
||||||
|
|
||||||
|
## Story 11 — Aktivitäten (activity feed) — NEW
|
||||||
|
|
||||||
|
**Route:** `/aktivitaeten`. **Prototype:** `Aktivitaeten.dc.html`.
|
||||||
|
|
||||||
|
PageHeader (eyebrow "Verlauf") + "Aktualisieren" button. Segmented control (Alle /
|
||||||
|
Transkription / Uploads / Personen). Feed grouped by day (Heute / Gestern / Diese Woche),
|
||||||
|
each group a UPPERCASE caption + rows. Each **row**: 40px avatar with a small **action-icon
|
||||||
|
badge** bottom-right (Check/Upload/Chat/Edit/…), then a sentence — bold actor name +
|
||||||
|
Montserrat verb + *italic underlined* target link — and a time sub.
|
||||||
|
|
||||||
|
**Done when:** grouping + icon badges match; target links styled with mint underline.
|
||||||
|
|
||||||
|
## Story 12 (optional) — Regeln (internal style reference)
|
||||||
|
|
||||||
|
**Prototype:** `Regeln.dc.html`. Internal page documenting the seven blocks (Typografie,
|
||||||
|
Farbe, Seitenkopf, Steuerung, Avatare, Metadaten/Leerzustände). Build only if the team wants
|
||||||
|
a living in-app reference; otherwise `DESIGN_RULES.md` is the canonical record. Gate behind
|
||||||
|
admin/dev.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order & dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
1 Tokens ─┬─ 2 Header ──┐
|
||||||
|
├─ 3 Avatar ──┼─→ 5 Dokumente, 6 Personen,
|
||||||
|
└─ 4 Primitives┘ 8 Geschichten → 9 Geschichte,
|
||||||
|
10 Zeitstrahl, 11 Aktivitäten (parallelizable)
|
||||||
|
12 Regeln (optional, last)
|
||||||
|
→ then Phase B (added screens), all depend on 1–4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B — Added screens (previously un-prototyped pages)
|
||||||
|
|
||||||
|
These are the pages the original handoff never covered. Each now has a hifi `.dc.html`
|
||||||
|
prototype in `prototypes/`. All depend on the shared primitives from Stories 1–4 and are
|
||||||
|
parallelizable among themselves. **Each maps to one Gitea page-issue** in the milestone.
|
||||||
|
Admin + OCR pages are explicitly **out of scope** here (phase-2 milestone).
|
||||||
|
|
||||||
|
| # | Screen | Prototype | Route(s) | Done when |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| B1 | Dokumente-Liste | `Dokumente-Liste.dc.html` | `/documents` | PageHeader; search card; AND/OR segmented; grouped mint-top cards; avatar-stack rows; 7px status dot+label; pagination |
|
||||||
|
| B2 | Dokument-Detail | `Dokument-Detail.dc.html` | `/documents/[id]` | compact top bar w/ mint accent bar; PDF pane + transcription panel w/ Lesen/Bearbeiten segmented + turquoise mode; details card |
|
||||||
|
| B3 | Dokument-Bearbeiten | `Dokument-Bearbeiten.dc.html` | `/documents/[id]/edit`, `/new`, `/bulk-edit` | split pane; progress strip; Wer&Wann + Beschreibung cards; dropzone (new); action bar (Löschen danger / Abbrechen / Zur Überprüfung / Speichern) |
|
||||||
|
| B4 | PersonDetail | `PersonDetail.dc.html` | `/persons/[id]` | PageHeader; 2-col mint-top cards; deterministic avatar; correspondents + relationships + letter lists |
|
||||||
|
| B5 | PersonForm | `PersonForm.dc.html` | `/persons/new`, `/[id]/edit` | PageHeader; Stammdaten card w/ type segmented; caps labels; Namensverlauf; edit-only merge danger zone; save bar |
|
||||||
|
| B6 | PersonReview | `PersonReview.dc.html` | `/persons/review` | PageHeader + count; row card w/ muted avatar; idle/rename/merge states; danger merge+delete; confirm dialog; empty state |
|
||||||
|
| B7 | Geschichte-Editor | `Geschichte-Editor.dc.html` | `/geschichten/new`, `/[id]/edit` | type-pick segmented; prose editor toolbar; journey editor w/ **color-only** drag; sidebar status+persons; save bar |
|
||||||
|
| B8 | Ereignis-Editor | `Ereignis-Editor.dc.html` | `/zeitstrahl/events/new`, `/[id]/edit` | PageHeader; Wann&Was card w/ type segmented + date precision + danger error; persons/docs sidebar; save bar |
|
||||||
|
| B9 | Stammbaum | `Stammbaum.dc.html` | `/stammbaum` | PageHeader + count; node cards w/ **§5 avatar** in resting/selected/dimmed; line connectors; side panel; zoom controls; empty state |
|
||||||
|
| B10 | Themen | `Themen.dc.html` | `/themen` | PageHeader + count; segmented filter; mint-top cards w/ **§1 tag-dot** (not stripe); child rows; empty state |
|
||||||
|
| B11 | Anreicherung | `Anreicherung.dc.html` | `/enrich`, `/[id]`, `/done` | list (status rows) / step (progress bar + split pane + action bar) / done (success card) |
|
||||||
|
| B12 | Profil | `Profil.dc.html` | `/profile`, `/users/[id]` | PageHeader; 2-col data/password cards; token banners; notifications; public profile card w/ avatar |
|
||||||
|
| B13 | Anmeldung | `Anmeldung.dc.html` | `/login`, `/register`, `/forgot-password`, `/reset-password` | self-contained branded shell (no app header); Tinos sentence-case titles; 17px inputs; token banners |
|
||||||
|
| B14 | Hilfe-Transkription | `Hilfe-Transkription.dc.html` | `/hilfe/transkription` | PageHeader; article column; rule cards w/ De Gruyter icons (no emoji); fixes `border-brand-sand`/`bg-white` bugs |
|
||||||
125
design_handoff_familienarchiv_redesign/README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Handoff: Familienarchiv — Visual Redesign ("Mappe" direction)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This package hands off a **complete visual redesign of the Familienarchiv web app** to a
|
||||||
|
developer using Claude Code. Familienarchiv is a private digital family archive
|
||||||
|
(German-first SvelteKit app) for digitising, transcribing, tagging and searching
|
||||||
|
historical correspondence.
|
||||||
|
|
||||||
|
The redesign unifies every page under one system — internally called **"Mappe"** (German
|
||||||
|
for *folder/portfolio*): a warm, archival, institutional look built on the De Gruyter Brill
|
||||||
|
corporate identity. It also **adds three new sections** that did not exist in the old app
|
||||||
|
(**Geschichten**, **Zeitstrahl**, **Aktivitäten**) alongside redesigns of the existing ones.
|
||||||
|
|
||||||
|
The work is broken into an **epic with foundation-first stories**. Read the three docs in
|
||||||
|
this order:
|
||||||
|
|
||||||
|
1. **`README.md`** (this file) — what's here, fidelity, how to use it.
|
||||||
|
2. **`DESIGN_RULES.md`** — the binding visual law (tokens, type, the shared patterns).
|
||||||
|
*Read this before writing any code.*
|
||||||
|
3. **`EPIC.md`** — the epic + ordered stories + acceptance criteria. *Implement in order.*
|
||||||
|
|
||||||
|
## About the design files
|
||||||
|
|
||||||
|
The files in `prototypes/` are **design references created in HTML** — high-fidelity
|
||||||
|
prototypes that show the intended look and behaviour exactly. **They are not the production
|
||||||
|
codebase and must not be shipped as-is.** They are authored as "Design Components"
|
||||||
|
(`.dc.html`) for a prototyping runtime (`support.js`); that runtime is **not** part of the
|
||||||
|
target app.
|
||||||
|
|
||||||
|
Your task is to **recreate these designs inside the existing Familienarchiv codebase**
|
||||||
|
(SvelteKit 2 + Svelte 5 + Tailwind 4 + Paraglide i18n) using its established patterns —
|
||||||
|
real Svelte components, real routes, real i18n message keys. Where the prototype uses a
|
||||||
|
made-up runtime construct (`<dc-import>`, `<sc-for>`, `renderVals()`), map it to the
|
||||||
|
codebase's idiom (a Svelte component import, an `{#each}` block, component props/state).
|
||||||
|
|
||||||
|
### Why HTML prototypes are the source of truth
|
||||||
|
|
||||||
|
Every colour, size, weight, spacing and radius is written **inline** in the prototype
|
||||||
|
markup, and the files **render**. When the written spec and a prototype disagree, **the
|
||||||
|
prototype wins** — open it in a browser and measure. Treat `DESIGN_RULES.md` and `EPIC.md`
|
||||||
|
as the *intent and ordering*, and the prototypes as the *pixel ground truth*.
|
||||||
|
|
||||||
|
To view a prototype: open any `prototypes/*.dc.html` in a browser (they are self-contained;
|
||||||
|
`colors_and_type.css`, `support.js` and `assets/icons/` sit alongside them). Use the
|
||||||
|
**Hell / Dunkel** toggle in the header to verify dark mode.
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
|
||||||
|
**High-fidelity (hifi).** Final colours, typography, spacing, radii, shadows, copy and
|
||||||
|
interaction intent are all decided. Recreate pixel-perfectly using the codebase's existing
|
||||||
|
libraries. Do not re-interpret the visual language — apply it.
|
||||||
|
|
||||||
|
## What's in this bundle
|
||||||
|
|
||||||
|
```
|
||||||
|
design_handoff_familienarchiv_redesign/
|
||||||
|
├── README.md ← you are here
|
||||||
|
├── DESIGN_RULES.md ← binding tokens + shared patterns (the "rules")
|
||||||
|
├── EPIC.md ← epic, stories, acceptance criteria (implement in order)
|
||||||
|
├── _AUTHORING_KIT.md ← the contract every prototype was authored against (copy-verbatim snippets)
|
||||||
|
└── prototypes/ ← runnable hifi references (source of truth)
|
||||||
|
│ ── original section screens ──
|
||||||
|
├── ArchiveHeader.dc.html shared header + nav + theme toggle
|
||||||
|
├── Dokumente.dc.html dashboard
|
||||||
|
├── Personen.dc.html person directory
|
||||||
|
├── Geschichten.dc.html curated story collections (list)
|
||||||
|
├── Geschichte.dc.html single story detail (2 variants)
|
||||||
|
├── Zeitstrahl.dc.html chronological timeline
|
||||||
|
├── Aktivitaeten.dc.html activity feed
|
||||||
|
├── Regeln.dc.html the design-rules spec page (internal reference)
|
||||||
|
│ ── added screens (the previously un-prototyped pages) ──
|
||||||
|
├── Dokumente-Liste.dc.html document search / grouped results
|
||||||
|
├── Dokument-Detail.dc.html document viewer + transcription workbench
|
||||||
|
├── Dokument-Bearbeiten.dc.html edit / new / bulk-edit (variant prop)
|
||||||
|
├── PersonDetail.dc.html person detail (read)
|
||||||
|
├── PersonForm.dc.html person new / edit (variant prop)
|
||||||
|
├── PersonReview.dc.html provisional-person triage workflow
|
||||||
|
├── Geschichte-Editor.dc.html story authoring: new / story / journey (variant prop)
|
||||||
|
├── Ereignis-Editor.dc.html timeline event new / edit (variant prop)
|
||||||
|
├── Stammbaum.dc.html family tree
|
||||||
|
├── Themen.dc.html topics / tag directory
|
||||||
|
├── Anreicherung.dc.html enrich workflow: list / step / done (variant prop)
|
||||||
|
├── Profil.dc.html account settings + public profile (variant prop)
|
||||||
|
├── Anmeldung.dc.html auth: login / register / forgot / reset (variant prop, no app header)
|
||||||
|
├── Hilfe-Transkription.dc.html transcription help / guidelines
|
||||||
|
│ ── shared assets ──
|
||||||
|
├── colors_and_type.css design tokens (port into the app)
|
||||||
|
├── support.js prototype runtime — DO NOT ship
|
||||||
|
└── assets/icons/ De Gruyter "Simple" icons used by the screens
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Status note (2026-06-16).** Two facts updated this bundle since the original handoff:
|
||||||
|
> 1. **This is now an alignment effort, not a greenfield reskin.** The token system
|
||||||
|
> (`DESIGN_RULES §1–2`), dark mode, the app header, and the three "new" sections
|
||||||
|
> (Geschichten, Zeitstrahl, Aktivitäten) **already exist in the codebase**. Foundation
|
||||||
|
> Stories 1–4 are *close-out + extraction* of the missing shared primitives (`PageHeader`,
|
||||||
|
> `Avatar`/`avatarFor`, `SegmentedControl`, `Card`, `MetaLine`, `EmptyState`, `StatusDot`),
|
||||||
|
> not from-scratch builds. See the Gitea milestone **"Mappe Visual Redesign"** for the
|
||||||
|
> issue-level breakdown (shared components, then pages).
|
||||||
|
> 2. **Briefwechsel was dropped** — that feature was removed from the product, so its
|
||||||
|
> prototype and nav entry are gone. Admin + OCR pages are deferred to a phase-2 milestone.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- **Icons** — De Gruyter "Simple" Medium-24px SVGs in `prototypes/assets/icons/`. In the
|
||||||
|
real app these live at `static/degruyter-icons/Simple/…` and are rendered as `<img>`
|
||||||
|
tags. Reuse the existing app copies; this bundle ships only the subset the redesign
|
||||||
|
touches so you can see which are needed.
|
||||||
|
- **Fonts** — Montserrat + Tinos via Google Fonts (substitutes for Gotham + Times). The
|
||||||
|
`@import` is at the top of `colors_and_type.css`. If the team has the licensed Gotham/Times
|
||||||
|
faces, swap them in and keep the variable names.
|
||||||
|
- **Logo** — none. The brand is the wordmark `FAMILIENARCHIV` (Montserrat Bold, uppercase,
|
||||||
|
`letter-spacing:.16em`) on the navy header.
|
||||||
|
|
||||||
|
## Target codebase notes
|
||||||
|
|
||||||
|
- **i18n**: all copy in the prototypes is German. The app is German-first with `en`/`es`
|
||||||
|
translations (Paraglide). Every visible string must become a message key, German written
|
||||||
|
first. Existing keys live in `frontend/messages/{de,en,es}.json`.
|
||||||
|
- **Icons as `<img>`**: keep the app convention — never inline SVG, never icon fonts. Dark
|
||||||
|
mode inverts them globally via `img[src*='degruyter-icons'] { filter: invert(1); }`.
|
||||||
|
- **Tailwind 4**: the prototypes use inline styles for clarity. Port them to the codebase's
|
||||||
|
Tailwind utilities / `@theme` tokens — but the *values* must match the tokens in
|
||||||
|
`DESIGN_RULES.md` exactly.
|
||||||
275
design_handoff_familienarchiv_redesign/_AUTHORING_KIT.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# Prototype Authoring Kit — "Mappe" redesign
|
||||||
|
|
||||||
|
You are authoring a **`.dc.html` design prototype** for the Familienarchiv redesign. These
|
||||||
|
prototypes are the **pixel ground truth** for one screen. They are NOT production code — they
|
||||||
|
run in a tiny prototyping runtime (`support.js`) and are opened directly in a browser.
|
||||||
|
|
||||||
|
**Read alongside this kit:** `DESIGN_RULES.md` (the binding visual law) and the existing
|
||||||
|
prototype you are told to use as a template. When this kit and `DESIGN_RULES.md` disagree,
|
||||||
|
`DESIGN_RULES.md` wins; when a rule and an existing rendered prototype disagree, the
|
||||||
|
prototype wins.
|
||||||
|
|
||||||
|
Everything below is **copy-verbatim**. Do not invent new tokens, colors, fonts, radii, or
|
||||||
|
spacings. Use only `var(--c-*)` / `var(--font-*)` / `var(--shadow-*)` and the literal values
|
||||||
|
shown here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. File skeleton (copy exactly; fill the `<main>` and the `renderVals()`)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="ACTIVE_KEY" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
<!-- PAGE CONTENT HERE -->
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
renderVals(){
|
||||||
|
return { /* data the template renders */ };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ACTIVE_KEY` ∈ `dokumente · personen · geschichten · zeitstrahl · aktivitaeten · stammbaum · themen · regeln`.
|
||||||
|
Pick the section your page belongs to (e.g. a document edit page → `dokumente`; a person
|
||||||
|
edit page → `personen`; a timeline event editor → `zeitstrahl`).
|
||||||
|
- **Auth pages (`Anmeldung`) have NO `ArchiveHeader`** — they get their own branded shell
|
||||||
|
(see §11).
|
||||||
|
|
||||||
|
## 2. Runtime constructs (this is all the runtime understands)
|
||||||
|
|
||||||
|
- `{{ expr }}` — interpolate a value from `renderVals()` (path access: `a.b`, `a[0]`). Works
|
||||||
|
in text and in any attribute, including `style="{{ obj }}"` where `obj` is a JS style
|
||||||
|
object.
|
||||||
|
- `<sc-for list="{{ items }}" as="x" hint-placeholder-count="6"> … {{ x.foo }} … </sc-for>`
|
||||||
|
- `<sc-if value="{{ flag }}" hint-placeholder-val="{{ false }}"> … </sc-if>`
|
||||||
|
- `onClick="{{ handler }}"` where `handler` is a function returned from `renderVals()`.
|
||||||
|
- The logic class is `class Component extends DCLogic`. `renderVals()` returns the flat data
|
||||||
|
object. Use `this.props.X` to read a prop. `this.state` + `this.setState({...})` for
|
||||||
|
interactivity (rarely needed — prototypes are mostly static).
|
||||||
|
- To inject a raw element from logic (e.g. an icon inside a loop), use
|
||||||
|
`React.createElement('img', { className:'dgicon', src:'assets/icons/Mail-MD.svg', style:{width:18,height:18,opacity:.5} })`.
|
||||||
|
- **camelCase event/style props** in the template: `onClick`, not `onclick`.
|
||||||
|
|
||||||
|
## 3. PageHeader (DESIGN_RULES §3) — every top-level page opens with this
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">EYEBROW</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Title</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Lede sentence, Sie-form.</p>
|
||||||
|
</div>
|
||||||
|
<!-- right slot: a count span OR a primary button (see §6) -->
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">147 Dokumente</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit/detail/form pages still open with a PageHeader (eyebrow like `PERSON BEARBEITEN`,
|
||||||
|
`EREIGNIS`, `NEUE PERSON`). Immersive split-pane workbenches (document detail viewer,
|
||||||
|
document/enrich edit) may use a compact top bar instead — follow your page brief.
|
||||||
|
|
||||||
|
## 4. Card (DESIGN_RULES §7)
|
||||||
|
|
||||||
|
Base card, **with the 3px mint top-border archival signature** (most content cards):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">…</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Variants: inline/resume strip uses `border-left:3px solid var(--c-accent)` instead of the top
|
||||||
|
border. Section caption inside a card:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Section title</h2>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Segmented control (DESIGN_RULES §6) — filters / view switches / binary type picks
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line)">
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:10px 16px; cursor:pointer">Alle</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 16px; border-left:1px solid var(--c-line); cursor:pointer">Zweitens</span>
|
||||||
|
<!-- repeat inactive segments; each adds border-left:1px solid var(--c-line) -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Active = `background:var(--c-primary); color:var(--c-primary-fg)`. Inactive =
|
||||||
|
`background:var(--c-surface); color:var(--c-ink-2)`.
|
||||||
|
|
||||||
|
## 6. Buttons (DESIGN_RULES §2 casing law — all UPPERCASE Montserrat 700)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- PRIMARY (navy) -->
|
||||||
|
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||||
|
|
||||||
|
<!-- SECONDARY (bordered) -->
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Abbrechen</button>
|
||||||
|
|
||||||
|
<!-- DANGER (destructive — Löschen) -->
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Löschen</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Form field + label (DESIGN_RULES §1, §2)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel</span>
|
||||||
|
<input placeholder="z.B. Brief an Clara" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Inputs are Tinos 16px (auth inputs 17px). Textareas same. Focus is shown by the runtime's
|
||||||
|
default outline; if you add a visible focus style use a 2px `var(--c-focus-ring)` outline with
|
||||||
|
2px offset — never remove focus without a replacement.
|
||||||
|
|
||||||
|
## 8. MetaLine (DESIGN_RULES §7) — ` · `-separated, optional leading icon
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">
|
||||||
|
<img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
<span>14. März 1923</span><span>·</span><span>14 Dokumente</span><span>·</span><span>4 Personen</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Status dot (DESIGN_RULES §7) — 7px circle + UPPERCASE label
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:#5a8a6a"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">Transkribiert</span>
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Status colors: Transkribiert / Veröffentlicht / Bestätigt `#5a8a6a` · In Arbeit `#c17a00` ·
|
||||||
|
Neu / Entwurf / Unbestätigt `#607080`. **Never color alone** — always the label too.
|
||||||
|
|
||||||
|
## 10. Empty state (DESIGN_RULES §7) — dashed, serif heading, German ellipsis
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="border:1px dashed var(--c-line); border-radius:2px; padding:48px 24px; text-align:center">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:20px; color:var(--c-ink); margin-bottom:8px">Noch keine Geschichten angelegt.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Beginnen Sie mit einem Brief…</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Auth shell (only for `Anmeldung.dc.html` — no ArchiveHeader)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="min-height:100vh; display:flex; flex-direction:column; background:var(--c-canvas)">
|
||||||
|
<header style="background:var(--c-header)">
|
||||||
|
<div style="height:4px; background:#a1dcd8"></div>
|
||||||
|
<div style="max-width:1180px; margin:0 auto; padding:0 32px; height:64px; display:flex; align-items:center; color:#fff">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:18px; font-weight:700; letter-spacing:.16em; text-transform:uppercase">Familienarchiv</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:40px 16px">
|
||||||
|
<div style="width:100%; max-width:400px; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 32px">
|
||||||
|
<h1 style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink); margin:0 0 8px">Anmelden</h1>
|
||||||
|
<!-- labelled 17px inputs (§7), full-width primary button (§6), secondary link row -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Title is **Tinos sentence-case** (NOT an uppercase chrome label). Inputs 17px. Register
|
||||||
|
variant uses `max-width:640px` and sectioned fields.
|
||||||
|
|
||||||
|
## 12. Icons (render as `<img class="dgicon">`, never inline SVG, never emoji)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:16px; height:16px; opacity:.4">
|
||||||
|
```
|
||||||
|
|
||||||
|
Available in `assets/icons/`: `Account-MD` · `Arrow-Right-MD` · `Bookmarks-MD` ·
|
||||||
|
`Calendar-Add-MD` · `Chat-MD` · `Check-MD` · `Copy-Item-MD` · `Edit-Content-MD` ·
|
||||||
|
`Filter-MD` · `Folder-MD` · `Globe-MD` · `Library-MD` · `Location-MD` · `Mag-Glass-MD` ·
|
||||||
|
`Mail-MD` · `Refresh-MD` · `Upload-MD` · `View-More-MD`. Use only these; pick the closest
|
||||||
|
match. Icons rest at `opacity:.4` (`.5` on meta lines). They invert automatically in dark
|
||||||
|
mode via the `.dgicon` rule in the skeleton.
|
||||||
|
|
||||||
|
## 13. Hard rules checklist (verify before you finish)
|
||||||
|
|
||||||
|
- [ ] Every color is a `var(--c-*)` token — **zero** raw hex except the `av()` palette and the
|
||||||
|
`#a1dcd8` mint stripe inside the header/auth shell. No `red-*`/`gray-*`/Tailwind.
|
||||||
|
- [ ] Casing law: UI chrome (labels, buttons, nav, captions, tags, status, eyebrow) is
|
||||||
|
**UPPERCASE Montserrat 700 + wide tracking**; headlines & body & names are **Tinos
|
||||||
|
sentence case**. Quotes use `„…"`; snippets are *italic*.
|
||||||
|
- [ ] Cards carry the 3px mint **top** border (or 3px mint **left** for inline strips).
|
||||||
|
- [ ] Touch targets ≥ 44px (`min-height:44px`) on buttons / interactive rows / icon buttons.
|
||||||
|
- [ ] Icon-only buttons would carry an `aria-label` in production — add `title="…"` in the
|
||||||
|
prototype so intent is clear.
|
||||||
|
- [ ] **No** transforms, scale, translate, blur, glassmorphism, gradients, or emoji. Motion is
|
||||||
|
color only.
|
||||||
|
- [ ] German, formal **Sie**. Use realistic family-archive content (Kurrent/Sütterlin letters
|
||||||
|
~1894–1945; people like Herbert Cram, Clara Cram, Marie Cram, Eugenie de Gruyter).
|
||||||
|
- [ ] Renders correctly in light AND dark — because you used tokens, it will. Do not hardcode
|
||||||
|
anything that breaks dark mode.
|
||||||
|
- [ ] Avatars use `this.av(name)` and the size objects (`a26/a28/a40/a48`). Same name → same
|
||||||
|
color (10-color palette).
|
||||||
|
|
||||||
|
## 14. Realistic data
|
||||||
|
|
||||||
|
Pull field names / sections from the **current Svelte page** you are given so the prototype
|
||||||
|
reflects what the app actually shows. Invent plausible German archival content for the values.
|
||||||
|
Aim for enough rows/items to show the layout breathing (e.g. 6–9 grid cards, 4–8 list rows).
|
||||||
|
|
||||||
|
## 15. Person chip (multiselect / token input)
|
||||||
|
|
||||||
|
A selected person inside a form (person multiselect, sender/receiver token input, etc.) is a
|
||||||
|
**square 2px rectangle chip** — `--radius-sm`, matching inputs/cards across the app. The
|
||||||
|
**avatar inside stays round**, but the chip container is **NOT** a round pill. (`radius-full`
|
||||||
|
in §1 is for avatars, dots, and status/count pills — not person chips.) Use this exact
|
||||||
|
shape everywhere a removable person appears:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:8px; background:var(--c-muted); border:1px solid var(--c-line); border-radius:2px; padding:4px 6px 4px 4px">
|
||||||
|
<span style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:14px; color:var(--c-ink)">{{ p.name }}</span>
|
||||||
|
<a href="#" title="Person entfernen" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; font-family:var(--font-sans); font-size:15px; font-weight:700; color:var(--c-ink-3); text-decoration:none">×</a>
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
The person **name** is content → Tinos serif. The chip itself is `border-radius:2px`. Wrap
|
||||||
|
chip lists in `display:flex; flex-wrap:wrap; gap:8px`. (A read-only correspondent link uses
|
||||||
|
the same square 2px container with no `×`.)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="aktivitaeten" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Verlauf</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Aktivitäten</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Wer zuletzt transkribiert, ergänzt und kuratiert hat — die Hände hinter dem Archiv.</p>
|
||||||
|
</div>
|
||||||
|
<button class="fa-link" style="display:inline-flex; align-items:center; gap:8px; background:var(--c-surface); border:1px solid var(--c-line); padding:10px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer">
|
||||||
|
<img class="dgicon" src="assets/icons/Refresh-MD.svg" style="width:15px; height:15px; opacity:.55"> Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- filter -->
|
||||||
|
<div style="margin-bottom:30px">
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line)">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 16px; cursor:pointer">Alle</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Transkription</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Uploads</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Personen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- feed -->
|
||||||
|
<sc-for list="{{ acts }}" as="g" hint-placeholder-count="3">
|
||||||
|
<div style="margin-bottom:30px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">{{ g.day }}</div>
|
||||||
|
<sc-for list="{{ g.items }}" as="a" hint-placeholder-count="3">
|
||||||
|
<div style="display:flex; gap:14px; align-items:flex-start; padding:16px 18px; background:var(--c-surface); border:1px solid var(--c-line); margin-bottom:10px">
|
||||||
|
<div style="position:relative; flex-shrink:0">
|
||||||
|
<span style="{{ a.a40 }}">{{ a.initials }}</span>
|
||||||
|
<span style="position:absolute; right:-4px; bottom:-4px; width:20px; height:20px; border-radius:999px; background:var(--c-surface); border:1px solid var(--c-line); display:flex; align-items:center; justify-content:center">
|
||||||
|
{{ a.iconEl }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:0; padding-top:2px">
|
||||||
|
<p style="margin:0; font-family:var(--font-sans); font-size:14px; line-height:1.5; color:var(--c-ink-2)">
|
||||||
|
<span style="font-weight:600; color:var(--c-ink)">{{ a.name }}</span> {{ a.verb }} <a href="#" class="fa-link" style="font-family:var(--font-serif); font-size:16px; font-style:italic; color:var(--c-ink); text-decoration:underline; text-decoration-color:var(--c-accent); text-underline-offset:3px; text-decoration-thickness:2px">{{ a.target }}</a>
|
||||||
|
</p>
|
||||||
|
<div style="margin-top:4px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ a.time }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name, a40:{ ...base, width:40, height:40, fontSize:14 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
activities(){
|
||||||
|
const mk = (actor, verb, target, time, icon) => ({ ...this.av(actor), verb, target, time, iconName:icon });
|
||||||
|
return [
|
||||||
|
{ day:'Heute', items:[
|
||||||
|
mk('Frieda Rose','hat die Transkription abgeschlossen von','Brief an Frieda aus dem Harz','09:42','Check-MD'),
|
||||||
|
mk('Karl Müller','hat 3 Seiten segmentiert in','Konvolut Rose, Heft III','08:15','Copy-Item-MD'),
|
||||||
|
mk('Anna Bauer','hat einen Kommentar hinterlassen zu','Postkarte aus Wien','07:51','Chat-MD'),
|
||||||
|
]},
|
||||||
|
{ day:'Gestern', items:[
|
||||||
|
mk('Otto Schmidt','hat zwei Dokumente hochgeladen in','Mappe B','17:30','Upload-MD'),
|
||||||
|
mk('Margarete Hoffmann','hat Metadaten ergänzt zu','Umschlag, 1924','14:05','Edit-Content-MD'),
|
||||||
|
mk('Wilhelm Rose','hat die Geschichte erstellt','Wilhelms Lehrjahre','11:20','Bookmarks-MD'),
|
||||||
|
]},
|
||||||
|
{ day:'Diese Woche', items:[
|
||||||
|
mk('Elise Vogt','hat zur Überprüfung freigegeben','Brief aus der Lehrzeit','Di.','Check-MD'),
|
||||||
|
mk('Heinrich Rose','ist dem Archiv beigetreten','','Mo.','Account-MD'),
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
return {
|
||||||
|
acts: this.activities().map(g => ({ ...g, items: g.items.map(a => ({ ...a, iconEl: React.createElement('img', { className:'dgicon', src:'assets/icons/'+a.iconName+'.svg', style:{ width:12, height:12, opacity:.65 } }) })) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<!-- ═══ AUTH SHELL (§11) — no ArchiveHeader on auth pages ═══ -->
|
||||||
|
<div style="min-height:100vh; display:flex; flex-direction:column; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
|
||||||
|
<!-- branded auth header: navy bar + 4px mint stripe + wordmark -->
|
||||||
|
<header style="background:var(--c-header)">
|
||||||
|
<div style="height:4px; background:#a1dcd8"></div>
|
||||||
|
<div style="max-width:1180px; margin:0 auto; padding:0 32px; height:64px; display:flex; align-items:center; color:#fff">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:18px; font-weight:700; letter-spacing:.16em; text-transform:uppercase">Familienarchiv</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:48px 16px">
|
||||||
|
<div style="width:100%; max-width:{{ cardMaxWidth }}">
|
||||||
|
|
||||||
|
<!-- ─────────────────────────────────────────────── LOGIN ── -->
|
||||||
|
<sc-if value="{{ isLogin }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 32px">
|
||||||
|
|
||||||
|
<h1 style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink); margin:0 0 6px">Anmelden</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:15px; color:var(--c-ink-2); margin:0 0 24px">Willkommen zurück im Familienarchiv.</p>
|
||||||
|
|
||||||
|
<!-- token-driven error banner (var(--c-danger)) -->
|
||||||
|
<div role="alert" style="display:flex; align-items:flex-start; gap:10px; margin-bottom:18px; border:1px solid var(--c-danger); border-left:3px solid var(--c-danger); background:var(--c-accent-bg); border-radius:2px; padding:11px 14px">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:var(--c-danger); margin-top:6px; flex-shrink:0"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-danger)">E-Mail oder Passwort ist nicht korrekt.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- rate-limit / warning note (tinted with accent-bg) -->
|
||||||
|
<div role="status" style="display:flex; align-items:flex-start; gap:10px; margin-bottom:22px; border:1px solid var(--c-line); border-left:3px solid var(--c-warning); background:var(--c-accent-bg); border-radius:2px; padding:11px 14px">
|
||||||
|
<img class="dgicon" src="assets/icons/Refresh-MD.svg" style="width:15px; height:15px; opacity:.5; margin-top:1px; flex-shrink:0">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-2)">Zu viele Versuche. Bitte warten Sie eine Minute, bevor Sie es erneut versuchen.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:18px">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">E-Mail</span>
|
||||||
|
<input type="email" autocomplete="email" placeholder="vorname@familie.de" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:24px">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Passwort</span>
|
||||||
|
<input type="password" autocomplete="current-password" placeholder="••••••••" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="fa-link" style="width:100%; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:12px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Anmelden</button>
|
||||||
|
|
||||||
|
<!-- secondary link row -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:20px; padding-top:18px; border-top:1px solid var(--c-line-2)">
|
||||||
|
<a href="Anmeldung.dc.html" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">Passwort vergessen?</a>
|
||||||
|
<a href="Anmeldung.dc.html" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">Konto erstellen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ───────────────────────────────────────── REGISTRIEREN ── -->
|
||||||
|
<sc-if value="{{ isRegister }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div>
|
||||||
|
<!-- hero: Montserrat eyebrow + Tinos ~38px title -->
|
||||||
|
<div style="text-align:center; margin-bottom:28px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:10px">Einladung angenommen</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:38px; line-height:1.15; color:var(--c-ink); margin:0">Konto erstellen</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:12px auto 0; max-width:440px">Legen Sie Ihren Zugang an, um Briefe zu lesen, zu transkribieren und mitzuschreiben.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 36px">
|
||||||
|
|
||||||
|
<!-- Section: Über dich -->
|
||||||
|
<section style="margin-bottom:30px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Über Sie</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:18px">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Vorname</span>
|
||||||
|
<input type="text" autocomplete="given-name" placeholder="z.B. Marie" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Nachname</span>
|
||||||
|
<input type="text" autocomplete="family-name" placeholder="z.B. Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section: Konto -->
|
||||||
|
<section style="margin-bottom:30px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Konto</h2>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">E-Mail</span>
|
||||||
|
<input type="email" autocomplete="email" placeholder="marie@familie.de" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Passwort with show/hide affordance -->
|
||||||
|
<label style="display:block; margin-bottom:8px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Passwort</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input type="password" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 64px 12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<button type="button" title="Passwort anzeigen" class="fa-link" style="position:absolute; right:6px; top:50%; transform:translateY(-50%); display:inline-flex; align-items:center; min-height:36px; padding:0 12px; background:transparent; border:none; font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); cursor:pointer">Zeigen</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:0 0 18px">Mindestens 8 Zeichen.</p>
|
||||||
|
|
||||||
|
<!-- Passwort bestätigen with show/hide affordance -->
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Passwort bestätigen</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input type="password" autocomplete="new-password" placeholder="Passwort wiederholen" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 64px 12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<button type="button" title="Passwort anzeigen" class="fa-link" style="position:absolute; right:6px; top:50%; transform:translateY(-50%); display:inline-flex; align-items:center; min-height:36px; padding:0 12px; background:transparent; border:none; font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); cursor:pointer">Zeigen</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section: Benachrichtigungen — checkbox card -->
|
||||||
|
<section style="margin-bottom:28px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 14px">Benachrichtigungen</h2>
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:14px; cursor:pointer; border:1px solid var(--c-primary); background:var(--c-accent-bg); border-radius:2px; padding:16px">
|
||||||
|
<span style="margin-top:1px; display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; flex-shrink:0; border:1px solid var(--c-primary); background:var(--c-primary); border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Check-MD.svg" style="width:13px; height:13px; filter:brightness(0) invert(1)">
|
||||||
|
</span>
|
||||||
|
<span style="min-width:0">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:14px; font-weight:700; color:var(--c-ink)">Bei Erwähnungen benachrichtigen</span>
|
||||||
|
<span style="display:block; font-family:var(--font-serif); font-size:15px; line-height:1.5; color:var(--c-ink-2); margin-top:3px">Erhalten Sie eine E-Mail, wenn jemand Sie in einem Kommentar oder einer Transkription erwähnt.</span>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" checked style="position:absolute; opacity:0; width:0; height:0">
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button class="fa-link" style="width:100%; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:12px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Registrieren</button>
|
||||||
|
|
||||||
|
<p style="text-align:center; font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3); margin:18px 0 0">
|
||||||
|
Schon ein Konto?
|
||||||
|
<a href="Anmeldung.dc.html" class="fa-link" style="font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">Anmelden</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ───────────────────────────────── PASSWORT VERGESSEN ── -->
|
||||||
|
<sc-if value="{{ isForgot }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 32px">
|
||||||
|
|
||||||
|
<h1 style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink); margin:0 0 8px">Passwort zurücksetzen</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:15px; line-height:1.6; color:var(--c-ink-2); margin:0 0 24px">Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Link, mit dem Sie ein neues Passwort vergeben können.</p>
|
||||||
|
|
||||||
|
<!-- success banner example (token-driven, accent-bg tint) -->
|
||||||
|
<div role="status" style="display:flex; align-items:flex-start; gap:10px; margin-bottom:22px; border:1px solid var(--c-line); border-left:3px solid var(--c-accent); background:var(--c-accent-bg); border-radius:2px; padding:12px 14px">
|
||||||
|
<img class="dgicon" src="assets/icons/Check-MD.svg" style="width:15px; height:15px; opacity:.5; margin-top:1px; flex-shrink:0">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-2)">Falls ein Konto zu dieser Adresse besteht, ist der Link unterwegs. Prüfen Sie Ihr Postfach.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:24px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">E-Mail</span>
|
||||||
|
<input type="email" autocomplete="email" placeholder="vorname@familie.de" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="fa-link" style="width:100%; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:12px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Link senden</button>
|
||||||
|
|
||||||
|
<div style="text-align:center; margin-top:20px; padding-top:18px; border-top:1px solid var(--c-line-2)">
|
||||||
|
<a href="Anmeldung.dc.html" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">Zurück zur Anmeldung</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ────────────────────────────── PASSWORT ZURÜCKSETZEN ── -->
|
||||||
|
<sc-if value="{{ isReset }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 32px">
|
||||||
|
|
||||||
|
<h1 style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink); margin:0 0 8px">Neues Passwort</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:15px; line-height:1.6; color:var(--c-ink-2); margin:0 0 24px">Vergeben Sie ein neues Passwort für Ihr Konto. Danach werden Sie zur Anmeldung weitergeleitet.</p>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Neues Passwort</span>
|
||||||
|
<input type="password" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:24px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Passwort bestätigen</span>
|
||||||
|
<input type="password" autocomplete="new-password" placeholder="Passwort wiederholen" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="fa-link" style="width:100%; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:12px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Passwort speichern</button>
|
||||||
|
|
||||||
|
<div style="text-align:center; margin-top:20px; padding-top:18px; border-top:1px solid var(--c-line-2)">
|
||||||
|
<a href="Anmeldung.dc.html" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">Zurück zur Anmeldung</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- quiet footer wordmark -->
|
||||||
|
<div style="padding:24px 16px; text-align:center">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:var(--c-ink-3)">Familienarchiv</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1100},"variant":{"editor":"enum","options":["login","registrieren","passwort-vergessen","passwort-zuruecksetzen"],"default":"login","tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
renderVals(){
|
||||||
|
const variant = this.props.variant || 'login';
|
||||||
|
const isLogin = variant === 'login';
|
||||||
|
const isRegister = variant === 'registrieren';
|
||||||
|
const isForgot = variant === 'passwort-vergessen';
|
||||||
|
const isReset = variant === 'passwort-zuruecksetzen';
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLogin, isRegister, isForgot, isReset,
|
||||||
|
cardMaxWidth: isRegister ? '640px' : '400px',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||||
|
|
||||||
|
<!-- ════════════ VARIANT: LISTE (start screen) ════════════ -->
|
||||||
|
<sc-if value="{{ isListe }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- PageHeader (§3) -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Aufgabe</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Anreicherung</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Ergänzen Sie fehlende Angaben Stück für Stück — wir führen Sie durch jeden Brief.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:flex-end; gap:10px">
|
||||||
|
<button class="fa-link" style="display:inline-flex; align-items:center; gap:8px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px"><img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:15px; height:15px; opacity:.7; filter:invert(1)">Starten</button>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ count }} Dokumente offen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: documents needing metadata (§4 — 3px mint top border) -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 8px">Benötigt Metadaten</h2>
|
||||||
|
|
||||||
|
<sc-for list="{{ docs }}" as="d" hint-placeholder-count="6">
|
||||||
|
<div style="display:flex; align-items:center; gap:16px; border-bottom:1px solid var(--c-line-2); padding:13px 0; min-height:44px">
|
||||||
|
<label style="display:inline-flex; align-items:center; justify-content:center; min-width:44px; min-height:44px; cursor:pointer; flex-shrink:0">
|
||||||
|
<input type="checkbox" style="width:18px; height:18px; accent-color:var(--c-primary); cursor:pointer">
|
||||||
|
</label>
|
||||||
|
<a href="Anreicherung.dc.html" class="fa-link" style="flex:1; min-width:0; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); text-decoration:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ d.title }}</a>
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px; width:128px; flex-shrink:0">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; flex-shrink:0; background:{{ d.dotColor }}"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ d.status }}</span>
|
||||||
|
</span>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; width:160px; flex-shrink:0; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">
|
||||||
|
<img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
<span>{{ d.date }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<p style="margin:16px 0 0; font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">{{ count }} Dokumente warten auf Pflichtangaben.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state (§10) — shown when nothing left to enrich -->
|
||||||
|
<sc-if value="{{ isEmpty }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="margin-top:18px; border:1px dashed var(--c-line); border-radius:2px; padding:48px 24px; text-align:center">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:20px; color:var(--c-ink); margin-bottom:8px">Alles angereichert — nichts zu tun.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Jeder Brief trägt Titel, Datum und Absender…</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</main>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ════════════ VARIANT: SCHRITT (guided editor) ════════════ -->
|
||||||
|
<sc-if value="{{ isSchritt }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex; flex-direction:column">
|
||||||
|
|
||||||
|
<!-- compact workflow top bar -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; border-bottom:1px solid var(--c-line); background:var(--c-surface); padding:12px 24px">
|
||||||
|
<a href="Anreicherung.dc.html" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; border:1px solid var(--c-line); background:var(--c-surface); padding:9px 14px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none; border-radius:2px">← Zurück</a>
|
||||||
|
<p style="flex:1; min-width:0; text-align:center; font-family:var(--font-serif); font-size:16px; font-weight:700; color:var(--c-ink); margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ doc.title }}</p>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); flex-shrink:0">Schritt {{ doc.step }} von {{ doc.total }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EnrichProgress block -->
|
||||||
|
<div style="display:flex; align-items:center; gap:14px; border-bottom:1px solid var(--c-line); background:var(--c-surface); padding:10px 24px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); white-space:nowrap">Pflichtfelder {{ doc.filled }} / {{ doc.required }}</span>
|
||||||
|
<div style="flex:1; height:4px; border-radius:999px; background:var(--c-line); overflow:hidden">
|
||||||
|
<div style="height:100%; border-radius:999px; background:var(--c-primary); width:{{ doc.progressPct }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- split pane -->
|
||||||
|
<div style="display:grid; grid-template-columns:6fr 4fr; align-items:stretch; min-height:calc(100vh - 68px - 110px)">
|
||||||
|
|
||||||
|
<!-- LEFT: PDF preview -->
|
||||||
|
<div style="border-right:1px solid var(--c-line); background:var(--c-pdf-bg); display:flex; flex-direction:column">
|
||||||
|
<div style="display:flex; align-items:center; border-bottom:1px solid var(--c-line); background:var(--c-surface); padding:8px 16px">
|
||||||
|
<label class="fa-link" style="margin-left:auto; display:inline-flex; align-items:center; gap:8px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); cursor:pointer"><img class="dgicon" src="assets/icons/Refresh-MD.svg" style="width:14px; height:14px; opacity:.5">Datei ersetzen</label>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; display:flex; align-items:flex-start; justify-content:center; padding:28px 16px; overflow:auto">
|
||||||
|
<div style="width:100%; max-width:520px; aspect-ratio:1 / 1.414; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-md); border-radius:2px; padding:40px 38px; display:flex; flex-direction:column; gap:14px">
|
||||||
|
<div style="font-family:var(--font-serif); font-style:italic; font-size:15px; color:var(--c-ink-3); text-align:right">Wien, im August 1924</div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink-2)">Meine liebe Clara,</div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:15px; line-height:1.8; color:var(--c-ink-3)">die Reise verlief gut, doch der Zug nach Mariahilf hatte Verspätung. Onkel Walter lässt herzlich grüßen und fragt nach den Kindern.</div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:15px; line-height:1.8; color:var(--c-ink-3)">Die Tage hier sind warm; ich denke oft an unseren Garten und an Euch alle daheim.</div>
|
||||||
|
<div style="margin-top:auto; font-family:var(--font-serif); font-style:italic; font-size:15px; color:var(--c-ink-3); text-align:right">In Liebe, Herbert</div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:8px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">Seite 1 von 1</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: metadata form -->
|
||||||
|
<div style="display:flex; flex-direction:column; background:var(--c-canvas)">
|
||||||
|
<div style="flex:1; overflow-y:auto; padding:20px; display:flex; flex-direction:column; gap:18px">
|
||||||
|
|
||||||
|
<!-- Wer & Wann card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Wer & Wann</h2>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Datum *</span>
|
||||||
|
<input value="02.08.1924" placeholder="TT.MM.JJJJ" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-top:7px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">
|
||||||
|
<span>Genauigkeit:</span><span style="color:var(--c-ink-2)">Tag</span><span>·</span><span>Monat</span><span>·</span><span>Jahr</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Absender *</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input value="Herbert Cram" placeholder="Person suchen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Empfänger</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input value="Clara Cram" placeholder="Personen hinzufügen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Ort</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input value="" placeholder="z.B. Wien" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Location-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beschreibung card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Beschreibung</h2>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel *</span>
|
||||||
|
<input value="Postkarte aus Wien" placeholder="z.B. Brief an Clara" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin:0 0 18px">
|
||||||
|
<div style="flex:1; height:1px; background:var(--c-line)"></div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3)">Optional</span>
|
||||||
|
<div style="flex:1; height:1px; background:var(--c-line)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Schlagworte</span>
|
||||||
|
<input value="Feldpost, Reise, Familie" placeholder="Schlagworte hinzufügen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Inhalt</span>
|
||||||
|
<textarea rows="4" placeholder="Kurze Zusammenfassung des Inhalts…" style="width:100%; resize:vertical; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; line-height:1.6; color:var(--c-ink); outline:none; border-radius:2px"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:18px">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Karton</span>
|
||||||
|
<input value="Mappe B" placeholder="z.B. Mappe B" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Mappe</span>
|
||||||
|
<input value="" placeholder="z.B. Heft III" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- action bar -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid var(--c-line); background:var(--c-surface); padding:14px 20px">
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">Überspringen</a>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px">
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||||
|
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern & geprüft</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ════════════ VARIANT: FERTIG (done) ════════════ -->
|
||||||
|
<sc-if value="{{ isFertig }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:80px 32px; display:flex; justify-content:center">
|
||||||
|
<div style="width:100%; max-width:520px; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:48px 40px; text-align:center">
|
||||||
|
<div style="width:56px; height:56px; margin:0 auto 22px; border-radius:999px; background:var(--c-accent-bg); display:flex; align-items:center; justify-content:center">
|
||||||
|
<img class="dgicon" src="assets/icons/Check-MD.svg" style="width:28px; height:28px; opacity:.7">
|
||||||
|
</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:32px; line-height:1.15; color:var(--c-ink); margin:0">Geschafft!</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:17px; line-height:1.6; color:var(--c-ink-2); margin:14px 0 0">Alle Briefe dieser Sitzung sind angereichert. Vielen Dank — Ihre Sorgfalt hält das Archiv beieinander.</p>
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:stretch; gap:12px; margin-top:30px">
|
||||||
|
<a href="Dokumente.dc.html" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:13px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; text-decoration:none; border-radius:2px">Zur Übersicht</a>
|
||||||
|
<a href="Anreicherung.dc.html" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:13px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; text-decoration:none; border-radius:2px">Zur Liste</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1100},"variant":{"editor":"enum","options":["liste","schritt","fertig"],"default":"schritt","tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
listDocs(){
|
||||||
|
const sc = { 'Neu':'#607080', 'In Arbeit':'#c17a00', 'Entwurf':'#607080' };
|
||||||
|
const raw = [
|
||||||
|
{ title:'Postkarte aus Wien', status:'In Arbeit', date:'2. August 1924' },
|
||||||
|
{ title:'Brief ohne Titel (Mappe B)', status:'Neu', date:'1924' },
|
||||||
|
{ title:'Umschlag — Absender unbekannt', status:'Neu', date:'November 1925' },
|
||||||
|
{ title:'Geschäftsbrief betr. Grundstück Mariahilf', status:'In Arbeit', date:'19. November 1925' },
|
||||||
|
{ title:'Glückwunsch zum 60. Geburtstag', status:'Entwurf', date:'22. Mai 1928' },
|
||||||
|
{ title:'Feldpostkarte, Datum unleserlich', status:'Neu', date:'ohne Datum' },
|
||||||
|
];
|
||||||
|
return raw.map(d => ({ ...d, dotColor: sc[d.status] || '#607080' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const variant = this.props.variant || 'schritt';
|
||||||
|
const docs = this.listDocs();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isListe: variant === 'liste',
|
||||||
|
isSchritt: variant === 'schritt',
|
||||||
|
isFertig: variant === 'fertig',
|
||||||
|
|
||||||
|
// liste
|
||||||
|
docs,
|
||||||
|
count: docs.length,
|
||||||
|
isEmpty: false,
|
||||||
|
|
||||||
|
// schritt
|
||||||
|
doc: {
|
||||||
|
title: 'Postkarte aus Wien',
|
||||||
|
step: 3,
|
||||||
|
total: 12,
|
||||||
|
filled: 2,
|
||||||
|
required: 3,
|
||||||
|
progressPct: '66.7%',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<header style="background:var(--c-header); position:sticky; top:0; z-index:50">
|
||||||
|
<div style="height:4px; background:#a1dcd8"></div>
|
||||||
|
<div style="max-width:1180px; margin:0 auto; padding:0 32px; height:64px; display:flex; align-items:center; color:#fff">
|
||||||
|
<a href="Dokumente.dc.html" style="font-family:var(--font-sans); font-size:18px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; margin-right:28px; white-space:nowrap; color:#fff; text-decoration:none">Familienarchiv</a>
|
||||||
|
<nav style="display:flex; gap:2px">
|
||||||
|
<a class="fa-link" href="Dokumente.dc.html" style="{{ nDok }}">Dokumente</a>
|
||||||
|
<a class="fa-link" href="Personen.dc.html" style="{{ nPers }}">Personen</a>
|
||||||
|
<a class="fa-link" href="Geschichten.dc.html" style="{{ nGesch }}">Geschichten</a>
|
||||||
|
<a class="fa-link" href="Zeitstrahl.dc.html" style="{{ nZeit }}">Zeitstrahl</a>
|
||||||
|
<a class="fa-link" href="Aktivitaeten.dc.html" style="{{ nAkt }}">Aktivitäten</a>
|
||||||
|
<a class="fa-link" href="Stammbaum.dc.html" style="{{ nStamm }}">Stammbaum</a>
|
||||||
|
<a class="fa-link" href="Themen.dc.html" style="{{ nThemen }}">Themen</a>
|
||||||
|
<a class="fa-link" href="Regeln.dc.html" style="{{ nReg }}">Regeln</a>
|
||||||
|
</nav>
|
||||||
|
<div style="margin-left:auto; display:flex; gap:16px; align-items:center">
|
||||||
|
<div style="display:flex; border:1px solid rgba(255,255,255,.25)">
|
||||||
|
<button class="fa-link" onClick="{{ setLight }}" style="{{ thLight }}">Hell</button>
|
||||||
|
<button class="fa-link" onClick="{{ setDark }}" style="{{ thDark }}">Dunkel</button>
|
||||||
|
</div>
|
||||||
|
<div style="width:32px; height:32px; border-radius:999px; background:#fff; color:#012851; display:flex; align-items:center; justify-content:center; font-family:var(--font-sans); font-size:11px; font-weight:700">MR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":68},"active":{"editor":"enum","options":["dokumente","personen","geschichten","zeitstrahl","aktivitaeten","stammbaum","themen","regeln"],"default":"dokumente","tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { theme: null };
|
||||||
|
stored(){ try { return localStorage.getItem('fa-theme'); } catch(e){ return null; } }
|
||||||
|
get theme(){ return this.state.theme ?? this.stored() ?? 'light'; }
|
||||||
|
apply(){ try { document.documentElement.dataset.theme = this.theme === 'dark' ? 'dark' : ''; } catch(e){} }
|
||||||
|
componentDidMount(){ this.apply(); }
|
||||||
|
componentDidUpdate(){ this.apply(); }
|
||||||
|
set(t){ try { localStorage.setItem('fa-theme', t); } catch(e){} this.setState({ theme: t }); }
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const active = this.props.active || 'dokumente';
|
||||||
|
const theme = this.theme;
|
||||||
|
const navBase = { color:'rgba(255,255,255,.6)', padding:'0 9px', fontFamily:'var(--font-sans)', fontSize:11, fontWeight:700, letterSpacing:'.07em', textTransform:'uppercase', textDecoration:'none', lineHeight:'44px', borderBottom:'2px solid transparent', marginTop:'10px', whiteSpace:'nowrap' };
|
||||||
|
const navOn = { ...navBase, color:'#fff', borderBottom:'2px solid #a1dcd8' };
|
||||||
|
const nav = (k) => active===k ? navOn : navBase;
|
||||||
|
const thBase = { fontFamily:'var(--font-sans)', fontSize:11, fontWeight:700, letterSpacing:'.1em', textTransform:'uppercase', padding:'5px 11px', cursor:'pointer', border:'none', background:'transparent', color:'rgba(255,255,255,.6)' };
|
||||||
|
const thOn = { ...thBase, background:'#a1dcd8', color:'#012851' };
|
||||||
|
return {
|
||||||
|
nDok:nav('dokumente'), nPers:nav('personen'), nGesch:nav('geschichten'), nZeit:nav('zeitstrahl'), nAkt:nav('aktivitaeten'), nStamm:nav('stammbaum'), nThemen:nav('themen'), nReg:nav('regeln'),
|
||||||
|
thLight: theme==='dark' ? thBase : thOn, thDark: theme==='dark' ? thOn : thBase,
|
||||||
|
setLight: () => this.set('light'), setDark: () => this.set('dark'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||||
|
|
||||||
|
<!-- ===== Compact top bar (immersive workbench — replaces full PageHeader) ===== -->
|
||||||
|
<div style="border-bottom:1px solid var(--c-line); background:var(--c-surface)">
|
||||||
|
<div style="max-width:1180px; margin:0 auto; padding:0 32px; height:54px; display:flex; align-items:center; gap:18px">
|
||||||
|
<!-- BackButton-style chip -->
|
||||||
|
<a href="{{ backHref }}" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">
|
||||||
|
<span style="font-size:15px; line-height:1">←</span>{{ backLabel }}
|
||||||
|
</a>
|
||||||
|
<span style="width:1px; height:22px; background:var(--c-line)"></span>
|
||||||
|
<!-- Truncated Tinos title -->
|
||||||
|
<span style="flex:1; min-width:0; font-family:var(--font-serif); font-size:18px; font-weight:700; color:var(--c-ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ workbenchTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Required-fields progress strip -->
|
||||||
|
<div style="max-width:1180px; margin:0 auto; padding:0 32px 12px; display:flex; align-items:center; gap:14px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); white-space:nowrap">Erforderliche Felder {{ reqFilled }} / {{ reqTotal }}</span>
|
||||||
|
<div style="flex:1; height:3px; border-radius:999px; background:var(--c-line); overflow:hidden">
|
||||||
|
<div style="height:100%; border-radius:999px; background:var(--c-primary); width:{{ reqPct }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Split pane ===== -->
|
||||||
|
<div style="max-width:1180px; margin:0 auto; padding:0 32px">
|
||||||
|
<div style="display:grid; grid-template-columns:46% 1fr; gap:0; border:1px solid var(--c-line); border-top:none">
|
||||||
|
|
||||||
|
<!-- LEFT: PDF preview OR empty dropzone (variant=neu) -->
|
||||||
|
<div style="background:var(--c-pdf-bg); border-right:1px solid var(--c-line); display:flex; flex-direction:column; min-height:680px">
|
||||||
|
|
||||||
|
<!-- variant: bearbeiten | sammel — PDF preview with "Datei ersetzen" -->
|
||||||
|
<sc-if value="{{ showPreview }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="display:flex; flex-direction:column; height:100%">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div style="display:flex; align-items:center; border-bottom:1px solid var(--c-line); background:var(--c-pdf-ctrl); padding:8px 14px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-pdf-text); opacity:.7">{{ pdfFilename }}</span>
|
||||||
|
<label class="fa-link" style="margin-left:auto; display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); padding:9px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Refresh-MD.svg" style="width:14px; height:14px; opacity:.5">Datei ersetzen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- Page surface -->
|
||||||
|
<div style="flex:1; display:flex; align-items:flex-start; justify-content:center; padding:28px; overflow:hidden">
|
||||||
|
<div style="width:340px; max-width:100%; aspect-ratio:1 / 1.41; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-md); padding:34px 30px; display:flex; flex-direction:column; gap:11px">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:13px; font-style:italic; color:var(--c-pdf-text); opacity:.85">Im Felde, den 12. März 1916</div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:14px; color:var(--c-pdf-text); line-height:1.7; opacity:.9">Meine innig geliebte Clara, nun sind es bald zwei Jahre, dass ich Dich nicht mehr in den Armen halten durfte …</div>
|
||||||
|
<div style="height:1px; background:var(--c-line); margin:4px 0"></div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:13px; color:var(--c-pdf-text); line-height:1.7; opacity:.55">… (Kurrentschrift, Seite 1 von 4) …</div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:13px; color:var(--c-pdf-text); line-height:1.7; opacity:.55">Dein Dich ewig liebender Herbert</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- variant: neu — empty dashed dropzone -->
|
||||||
|
<sc-if value="{{ showDropzone }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:32px">
|
||||||
|
<label class="fa-link" style="display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; width:100%; max-width:420px; min-height:300px; border:2px dashed var(--c-accent); border-radius:2px; background:var(--c-accent-bg); padding:40px 28px; text-align:center; cursor:pointer">
|
||||||
|
<img class="dgicon" src="assets/icons/Upload-MD.svg" style="width:34px; height:34px; opacity:.5">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:15px; font-weight:700; color:var(--c-ink-2)">Dateien hier ablegen oder klicken</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">PDF, JPG, PNG, TIFF bis 50 MB</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: scrollable form column -->
|
||||||
|
<div style="background:var(--c-surface); display:flex; flex-direction:column; min-height:680px">
|
||||||
|
<div style="flex:1; padding:24px; display:flex; flex-direction:column; gap:18px">
|
||||||
|
|
||||||
|
<!-- variant: sammel — shared-metadata note -->
|
||||||
|
<sc-if value="{{ isBulk }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="background:var(--c-accent-bg); border:1px solid var(--c-line); border-left:3px solid var(--c-accent); padding:14px 16px; border-radius:2px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); margin-bottom:4px">Geteilte Metadaten</div>
|
||||||
|
<p style="margin:0; font-family:var(--font-serif); font-size:15px; font-style:italic; color:var(--c-ink-2)">Felder werden ergänzt, nicht ersetzt.</p>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- CARD: Wer & Wann -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 18px">Wer & Wann</h2>
|
||||||
|
|
||||||
|
<!-- Absender (typeahead, required) -->
|
||||||
|
<label style="display:block; margin-bottom:16px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Absender *</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input value="{{ f.sender }}" placeholder="Person suchen … z.B. Herbert Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Empfänger -->
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Empfänger {{ additiveSuffix }}</span>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px; border:1px solid var(--c-line); background:var(--c-surface); padding:8px 10px; border-radius:2px; min-height:44px">
|
||||||
|
<sc-for list="{{ f.receivers }}" as="r" hint-placeholder-count="2">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px; background:var(--c-muted); border:1px solid var(--c-line); padding:4px 10px 4px 5px; border-radius:2px">
|
||||||
|
<span style="{{ r.a26 }}">{{ r.initials }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:14px; color:var(--c-ink)">{{ r.name }}</span>
|
||||||
|
</span>
|
||||||
|
</sc-for>
|
||||||
|
<input placeholder="{{ receiverPlaceholder }}" style="flex:1; min-width:120px; border:none; background:transparent; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Datum + Präzision -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 150px; gap:12px; margin-bottom:16px">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Datum *</span>
|
||||||
|
<input value="{{ f.date }}" placeholder="TT.MM.JJJJ" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Genauigkeit</span>
|
||||||
|
<select style="width:100%; appearance:none; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px; cursor:pointer; min-height:44px">
|
||||||
|
<option selected>Tag</option><option>Monat</option><option>Jahr</option><option>Unbekannt</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ort -->
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Ort</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input value="{{ f.location }}" placeholder="z.B. Im Felde, Galizien" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Location-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CARD: Beschreibung -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 18px">Beschreibung</h2>
|
||||||
|
|
||||||
|
<!-- Titel -->
|
||||||
|
<label style="display:block; margin-bottom:16px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel *</span>
|
||||||
|
<input value="{{ f.title }}" placeholder="z.B. Feldpostbrief an Clara" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Notiz / Inhalt -->
|
||||||
|
<label style="display:block; margin-bottom:16px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Notiz</span>
|
||||||
|
<textarea rows="4" placeholder="Kurze Beschreibung oder erste Notizen zum Inhalt …" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px; resize:vertical">{{ f.note }}</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Tags / Schlagworte -->
|
||||||
|
<div>
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Schlagworte {{ additiveSuffix }}</span>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px; border:1px solid var(--c-line); background:var(--c-surface); padding:8px 10px; border-radius:2px; min-height:44px">
|
||||||
|
<sc-for list="{{ f.tags }}" as="t" hint-placeholder-count="3">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px; background:var(--c-muted); border:1px solid var(--c-line); padding:4px 10px; border-radius:4px">
|
||||||
|
<span style="width:8px; height:8px; border-radius:999px; background:{{ t.dot }}"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.13em; text-transform:uppercase; color:var(--c-ink-2)">{{ t.label }}</span>
|
||||||
|
</span>
|
||||||
|
</sc-for>
|
||||||
|
<input placeholder="Schlagwort hinzufügen …" style="flex:1; min-width:120px; border:none; background:transparent; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky action bar -->
|
||||||
|
<div style="position:sticky; bottom:0; display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid var(--c-line); background:var(--c-surface); padding:14px 24px">
|
||||||
|
<sc-if value="{{ isEdit }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<a href="#" class="fa-link" title="Dokument löschen" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Löschen</a>
|
||||||
|
</sc-if>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; margin-left:auto">
|
||||||
|
<a href="{{ backHref }}" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 18px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px; text-decoration:none">Abbrechen</a>
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 18px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Zur Überprüfung</button>
|
||||||
|
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 22px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="height:48px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":840},"variant":{"editor":"enum","options":["bearbeiten","neu","sammel"],"default":"bearbeiten","tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
rcv(name){ const a = this.av(name); return { name, initials:a.initials, a26:a.a26 }; }
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const variant = this.props.variant || 'bearbeiten';
|
||||||
|
const isEdit = variant === 'bearbeiten';
|
||||||
|
const isNew = variant === 'neu';
|
||||||
|
const isBulk = variant === 'sammel';
|
||||||
|
|
||||||
|
const tagPal = {
|
||||||
|
sage:'#5a8a6a', sienna:'#a0522d', amber:'#c17a00', slate:'#607080',
|
||||||
|
violet:'#7a4f9a', cobalt:'#3060b0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const editing = {
|
||||||
|
sender:'Herbert Cram',
|
||||||
|
receivers:[ this.rcv('Clara Cram') ],
|
||||||
|
date:'12.03.1916',
|
||||||
|
location:'Im Felde, Galizien',
|
||||||
|
title:'Feldpostbrief an Clara — „nun sind es bald zwei Jahre“',
|
||||||
|
note:'Vierseitiger Feldpostbrief in Kurrentschrift. Herbert schreibt aus dem Stellungskrieg an seine Frau Clara. Teil des Konvoluts 1914–1918.',
|
||||||
|
tags:[
|
||||||
|
{ label:'Feldpost', dot:tagPal.sienna },
|
||||||
|
{ label:'1. Weltkrieg', dot:tagPal.slate },
|
||||||
|
{ label:'Galizien', dot:tagPal.sage },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const blank = {
|
||||||
|
sender:'', receivers:[], date:'', location:'', title:'', note:'', tags:[],
|
||||||
|
};
|
||||||
|
const bulk = {
|
||||||
|
sender:'', receivers:[], date:'', location:'', title:'', note:'',
|
||||||
|
tags:[ { label:'Feldpost', dot:tagPal.sienna } ],
|
||||||
|
};
|
||||||
|
const f = isEdit ? editing : (isBulk ? bulk : blank);
|
||||||
|
|
||||||
|
// Required progress: bearbeiten=alle 3, neu=0, sammel=2 (Absender+Datum geteilt, Titel pro Datei).
|
||||||
|
const reqFilled = isEdit ? 3 : (isBulk ? 2 : 0);
|
||||||
|
const reqTotal = 3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEdit, isNew, isBulk,
|
||||||
|
showPreview: isEdit || isBulk,
|
||||||
|
showDropzone: isNew,
|
||||||
|
backHref: isEdit ? 'Dokumente.dc.html' : 'Dokumente-Liste.dc.html',
|
||||||
|
backLabel: 'Zurück',
|
||||||
|
workbenchTitle: isBulk
|
||||||
|
? '3 Dokumente · Gemeinsame Metadaten'
|
||||||
|
: (isNew ? 'Neues Dokument' : 'Feldpostbrief an Clara — „nun sind es bald zwei Jahre“'),
|
||||||
|
pdfFilename: isBulk ? 'feldpost_1916_03_12.pdf' : 'cram_feldpost_1916.pdf',
|
||||||
|
reqFilled, reqTotal,
|
||||||
|
reqPct: Math.round((reqFilled / reqTotal) * 100),
|
||||||
|
additiveSuffix: isBulk ? '· wird ergänzt' : '',
|
||||||
|
receiverPlaceholder: f.receivers.length ? 'Weitere hinzufügen …' : 'Person suchen …',
|
||||||
|
f,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||||
|
|
||||||
|
<!-- ── Compact top bar (immersive workbench — no big PageHeader) ── -->
|
||||||
|
<div style="position:relative; z-index:10; border-bottom:1px solid var(--c-line); background:var(--c-surface); box-shadow:var(--shadow-sm)">
|
||||||
|
<div style="display:flex; align-items:center; gap:0; padding-right:16px; min-height:76px">
|
||||||
|
|
||||||
|
<!-- Back chip -->
|
||||||
|
<a href="Dokumente.dc.html" class="fa-link" title="Zurück zur Übersicht" style="display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:44px; padding:0 14px 0 16px; margin-right:6px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">← Zurück</a>
|
||||||
|
|
||||||
|
<div style="width:1px; height:24px; background:var(--c-line); flex-shrink:0; margin-right:14px"></div>
|
||||||
|
|
||||||
|
<!-- Title block with 3px MINT left accent bar -->
|
||||||
|
<div style="border-left:3px solid var(--c-accent); padding-left:14px; min-width:0; flex:1">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:3px">{{ signatur }} · Feldpost</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:21px; line-height:1.2; color:var(--c-ink); margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlapping sender/receiver avatar stack (a26) -->
|
||||||
|
<div style="display:flex; align-items:center; padding-left:12px; margin-right:14px; flex-shrink:0">
|
||||||
|
<sc-for list="{{ stack }}" as="p" hint-placeholder-count="2">
|
||||||
|
<span title="{{ p.name }}" style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status dot + UPPERCASE label (§9) -->
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px; flex-shrink:0; margin-right:14px">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:{{ statusColor }}"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ status }}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style="width:1px; height:24px; background:var(--c-line); flex-shrink:0; margin-right:14px"></div>
|
||||||
|
|
||||||
|
<!-- Details toggle (secondary, active state) -->
|
||||||
|
<button class="fa-link" aria-expanded="true" style="display:inline-flex; align-items:center; gap:7px; min-height:44px; background:var(--c-primary); color:var(--c-primary-fg); border:1px solid var(--c-primary); padding:0 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; cursor:pointer; border-radius:2px; flex-shrink:0">
|
||||||
|
Details
|
||||||
|
<span style="font-size:9px; line-height:1">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style="width:1px; height:24px; background:var(--c-line); flex-shrink:0; margin:0 14px"></div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; flex-shrink:0">
|
||||||
|
<button class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:0 18px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px"><img class="dgicon" src="assets/icons/Edit-Content-MD.svg" style="width:15px; height:15px; opacity:.9; filter:invert(1) brightness(2)">Transkribieren</button>
|
||||||
|
<button class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:0 16px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Bearbeiten</button>
|
||||||
|
<button class="fa-link" title="Weitere Aktionen" style="display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); cursor:pointer; border-radius:2px; flex-shrink:0"><img class="dgicon" src="assets/icons/View-More-MD.svg" style="width:18px; height:18px; opacity:.55"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Slide-over "Details" metadata card (3px mint TOP border) ── -->
|
||||||
|
<div style="border-top:3px solid var(--c-accent); background:var(--c-surface); padding:26px 32px">
|
||||||
|
<div style="max-width:1180px; margin:0 auto; display:grid; grid-template-columns:1fr 1.4fr 1fr 1fr; gap:30px; align-items:start">
|
||||||
|
|
||||||
|
<!-- Details column -->
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">Details</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:14px">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:7px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:3px"><img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="width:13px; height:13px; opacity:.5">Datum</div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">31. Oktober 1915</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:7px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:3px"><img class="dgicon" src="assets/icons/Location-MD.svg" style="width:13px; height:13px; opacity:.5">Ort</div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">Feldpost — Westfront</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:5px">Status</div>
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:{{ statusColor }}"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ status }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personen column (avatar chips a40) -->
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">Personen</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:6px">Absender</div>
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:10px; padding:6px 8px; margin:0 -8px 10px; text-decoration:none; border-radius:2px">
|
||||||
|
<span style="{{ sender.a40 }}">{{ sender.initials }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">{{ sender.name }}</span>
|
||||||
|
</a>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:6px">Empfänger</div>
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:10px; padding:6px 8px; margin:0 -8px; text-decoration:none; border-radius:2px">
|
||||||
|
<span style="{{ receiver.a40 }}">{{ receiver.initials }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">{{ receiver.name }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schlagwörter column (tag chips) -->
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">Schlagwörter</div>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:8px">
|
||||||
|
<sc-for list="{{ tags }}" as="t" hint-placeholder-count="4">
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; gap:6px; background:var(--c-muted); border:1px solid var(--c-line); padding:5px 10px; border-radius:4px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.13em; text-transform:uppercase; color:var(--c-ink); text-decoration:none">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:{{ t.dot }}"></span>{{ t.name }}
|
||||||
|
</a>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Geschichten + comment/chat affordance -->
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">Geschichten</div>
|
||||||
|
<a href="Geschichte.dc.html" class="fa-link" style="display:block; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); text-decoration:none; margin-bottom:3px">Feldpost: Herbert an der Westfront 1914–1918</a>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:18px">Marcel Raddatz · 11. Juni 2026</div>
|
||||||
|
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); padding:9px 14px; border-radius:2px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">
|
||||||
|
<img class="dgicon" src="assets/icons/Chat-MD.svg" style="width:15px; height:15px; opacity:.55">3 Anmerkungen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Main split pane ── -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 480px; min-height:640px; align-items:stretch">
|
||||||
|
|
||||||
|
<!-- LEFT: document/PDF viewer placeholder on --c-pdf-bg -->
|
||||||
|
<div style="display:flex; flex-direction:column; background:var(--c-pdf-bg); min-height:640px">
|
||||||
|
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:36px">
|
||||||
|
<!-- aged-scan page placeholder -->
|
||||||
|
<div style="width:100%; max-width:540px; aspect-ratio:3 / 4; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-md); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:46px; height:46px; opacity:.3">
|
||||||
|
<div style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-3)">Feldpostbrief — Seite 1</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3)">{{ signatur }} · Kurrentschrift</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- page-control footer (prev/next, page x of y) -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:16px; padding:12px; background:var(--c-pdf-ctrl); border-top:1px solid var(--c-line)">
|
||||||
|
<button class="fa-link" title="Vorherige Seite" style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; cursor:pointer; color:var(--c-pdf-text); font-family:var(--font-sans); font-size:16px; font-weight:700">‹</button>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:600; letter-spacing:.04em; color:var(--c-pdf-text)">Seite 1 von 2</span>
|
||||||
|
<button class="fa-link" title="Nächste Seite" style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; cursor:pointer; color:var(--c-pdf-text); font-family:var(--font-sans); font-size:16px; font-weight:700">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: transcription panel -->
|
||||||
|
<div style="display:flex; flex-direction:column; background:var(--c-surface); border-left:1px solid var(--c-line); min-height:640px">
|
||||||
|
|
||||||
|
<!-- panel header: read/edit SEGMENTED CONTROL + turquoise accent chip -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; padding:14px 18px; border-bottom:1px solid var(--c-line)">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px">
|
||||||
|
<!-- segmented control (§6) -->
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line); border-radius:2px; overflow:hidden">
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 16px; cursor:pointer">Lesen</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Bearbeiten</span>
|
||||||
|
</div>
|
||||||
|
<!-- turquoise Transkriptionsmodus accent chip -->
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px; border:1px solid var(--c-turquoise); padding:5px 10px; border-radius:999px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-turquoise)">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:var(--c-turquoise)"></span>Transkriptionsmodus
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">4 Abschnitte · zuletzt 12. Juni 2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- transcription blocks in Tinos -->
|
||||||
|
<div style="flex:1; overflow-y:auto; padding:26px 28px">
|
||||||
|
<sc-for list="{{ blocks }}" as="b" hint-placeholder-count="4">
|
||||||
|
<div style="{{ b.wrap }}">
|
||||||
|
<p style="font-family:var(--font-serif); font-size:16px; line-height:1.85; color:var(--c-ink); margin:0">{{ b.text }}</p>
|
||||||
|
<sc-if value="{{ b.annotated }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:7px; margin-top:10px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3)">
|
||||||
|
<img class="dgicon" src="assets/icons/Chat-MD.svg" style="width:13px; height:13px; opacity:.5">Markiert · 1 Anmerkung
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10, marginLeft:-6, border:'2px solid var(--c-surface)' },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks(){
|
||||||
|
const raw = [
|
||||||
|
{ annotated:false, text:'Meine liebe, gute Clara! Heute hatte ich endlich wieder einen ruhigen Tag, um Dir ausführlich zu schreiben. Das Wetter ist umgeschlagen, kalter Regen seit gestern, und die Wege sind grundlos. Trotzdem geht es mir gut, sei darum unbesorgt.' },
|
||||||
|
{ annotated:true, text:'In 10 Minuten gehe ich in Stellung zum ehrenvollen, heissen und schweren Kampfe. Was kommen mag, ich gehe ruhig hinein – nur der Gedanke an Dich und die Kinder läßt mich nicht los, Tag und Nacht.' },
|
||||||
|
{ annotated:false, text:'Hast Du meine letzten beiden Karten erhalten? Schreibe mir bald, wie es Mutter geht und ob die Kohlen für den Winter schon eingebracht sind. Grüße mir die Nachbarn und besonders Frau Hoffmann recht herzlich.' },
|
||||||
|
{ annotated:false, text:'Nun muß ich schließen, der Posten wartet. Bleib gesund und tapfer, meine Gute. In treuer Liebe und mit tausend Küssen, Dein Herbert.' },
|
||||||
|
];
|
||||||
|
return raw.map(b => ({
|
||||||
|
...b,
|
||||||
|
wrap: b.annotated
|
||||||
|
? { borderLeft:'3px solid var(--c-accent)', paddingLeft:18, paddingTop:14, paddingBottom:18, marginBottom:8, background:'var(--c-accent-bg)' }
|
||||||
|
: { paddingTop:14, paddingBottom:18, marginBottom:8, borderBottom:'1px solid var(--c-line)' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const sender = this.av('Herbert Cram');
|
||||||
|
const receiver = this.av('Clara Cram');
|
||||||
|
const tagColors = { 'Feldpost':'#a0522d', 'Erster Weltkrieg':'#607080', 'Westfront':'#3060b0', 'Liebesbrief':'#c0446e' };
|
||||||
|
const tags = Object.keys(tagColors).map(name => ({ name, dot:tagColors[name] }));
|
||||||
|
return {
|
||||||
|
signatur:'H-0366',
|
||||||
|
title:'Feldpostbrief an Clara — »in 10 Minuten gehe ich in Stellung«',
|
||||||
|
status:'Transkribiert',
|
||||||
|
statusColor:'#5a8a6a',
|
||||||
|
sender, receiver,
|
||||||
|
stack:[sender, receiver],
|
||||||
|
tags,
|
||||||
|
blocks: this.blocks(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- PAGE HEADER -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Archiv</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Dokumente</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Durchsuchen Sie den Bestand nach Titel, Person oder Schlagwort — gebündelt nach Jahr.</p>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">147 Dokumente · 38 Personen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEARCH CARD -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:16px; margin-bottom:16px">
|
||||||
|
<!-- row 1: search + sort + filter -->
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap">
|
||||||
|
<div style="position:relative; flex:1; min-width:240px">
|
||||||
|
<input placeholder="Titel, Personen, Tags durchsuchen…" style="width:100%; box-sizing:border-box; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
<button class="fa-link" style="display:flex; align-items:center; gap:8px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); padding:11px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px">Datum ↓</button>
|
||||||
|
<button class="fa-link" style="display:flex; align-items:center; gap:8px; min-height:44px; background:var(--c-muted); border:1px solid var(--c-line); padding:11px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px"><img class="dgicon" src="assets/icons/Filter-MD.svg" style="width:15px; height:15px; opacity:.55">Filter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- row 2: advanced filters (shown open) -->
|
||||||
|
<div style="margin-top:18px; padding-top:18px; border-top:1px solid var(--c-line-2); display:flex; flex-direction:column; gap:18px">
|
||||||
|
|
||||||
|
<!-- tags + AND/OR segmented control -->
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Schlagwörter</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap">
|
||||||
|
<sc-for list="{{ tagChips }}" as="t" hint-placeholder-count="3">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px; border:1px solid var(--c-line); background:var(--c-muted); padding:5px 10px; border-radius:4px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.13em; text-transform:uppercase; color:var(--c-ink-2)">
|
||||||
|
<span style="{{ t.dot }}"></span>{{ t.name }}
|
||||||
|
</span>
|
||||||
|
</sc-for>
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line); margin-left:4px">
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 16px; cursor:pointer">Und</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Oder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- typeahead + dates + undated toggle -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr 1fr 1fr; gap:14px; align-items:end">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Absender</span>
|
||||||
|
<input placeholder="z.B. Herbert Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Empfänger</span>
|
||||||
|
<input placeholder="z.B. Clara Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Von</span>
|
||||||
|
<input placeholder="01.01.1914" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Bis</span>
|
||||||
|
<input placeholder="31.12.1918" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- undated-only toggle -->
|
||||||
|
<div>
|
||||||
|
<button class="fa-link" title="Nur undatierte Dokumente anzeigen" style="display:inline-flex; align-items:center; gap:10px; min-height:44px; border:1px solid var(--c-line); background:var(--c-muted); padding:0 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px">
|
||||||
|
<span style="display:inline-flex; width:16px; height:16px; border:1px solid var(--c-ink-3); border-radius:2px"></span>
|
||||||
|
Ohne Datum
|
||||||
|
<span style="display:inline-flex; align-items:center; justify-content:center; min-width:24px; padding:2px 6px; border-radius:999px; background:var(--c-line); color:var(--c-ink-2); font-size:11px; font-weight:700; letter-spacing:0">9</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RESULT COUNT + ACTIONS -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:18px; flex-wrap:wrap">
|
||||||
|
<p style="margin:0; font-family:var(--font-sans); font-size:14px; color:var(--c-ink-2)">147 Treffer</p>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px">
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px; text-decoration:none"><img class="dgicon" src="assets/icons/Edit-Content-MD.svg" style="width:15px; height:15px; opacity:.55">Alle bearbeiten</a>
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px; text-decoration:none">Neu</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GROUPED RESULT CARDS -->
|
||||||
|
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="3">
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; margin-bottom:18px">
|
||||||
|
<div style="border-bottom:1px solid var(--c-line-2); background:var(--c-muted); padding:11px 24px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3)">{{ g.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<sc-for list="{{ g.docs }}" as="d" hint-placeholder-count="5">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; border-bottom:1px solid var(--c-line-2); padding:15px 24px">
|
||||||
|
<a href="#" class="fa-link" style="font-family:var(--font-serif); font-size:18px; color:var(--c-ink); text-decoration:none; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1">{{ d.title }}</a>
|
||||||
|
<div style="display:flex; align-items:center; gap:18px; flex-shrink:0">
|
||||||
|
<div style="display:flex; padding-left:6px">
|
||||||
|
<sc-for list="{{ d.people }}" as="p" hint-placeholder-count="3">
|
||||||
|
<span style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); width:130px; text-align:right">{{ d.date }}</span>
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px; width:120px">
|
||||||
|
<span style="{{ d.statusDot }}"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ d.status }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<!-- PAGINATION -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:8px; margin-top:28px">
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none; border-radius:2px">Zurück</a>
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-primary); background:var(--c-primary); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--c-primary-fg); text-decoration:none; border-radius:2px">1</a>
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--c-ink-2); text-decoration:none; border-radius:2px">2</a>
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--c-ink-2); text-decoration:none; border-radius:2px">3</a>
|
||||||
|
<span style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; font-family:var(--font-sans); font-size:12px; font-weight:700; color:var(--c-ink-3)">…</span>
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--c-ink-2); text-decoration:none; border-radius:2px">8</a>
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none; border-radius:2px">Weiter</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name, a26:{ ...base, width:26, height:26, fontSize:10, marginLeft:-6, border:'2px solid var(--c-surface)' } };
|
||||||
|
}
|
||||||
|
avs(names){ return names.map(n => this.av(n)); }
|
||||||
|
|
||||||
|
// Real DocumentStatus labels (documentStatusLabel.ts) mapped to the 3 status colors.
|
||||||
|
statusColor(status){
|
||||||
|
const sc = {
|
||||||
|
'Transkribiert':'#5a8a6a', 'Geprüft':'#5a8a6a', 'Archiviert':'#5a8a6a',
|
||||||
|
'Hochgeladen':'#c17a00',
|
||||||
|
'Platzhalter':'#607080',
|
||||||
|
};
|
||||||
|
return sc[status] || '#607080';
|
||||||
|
}
|
||||||
|
|
||||||
|
groups(){
|
||||||
|
const tag = (t) => ({ ...t, statusDot:{ display:'inline-block', width:7, height:7, borderRadius:'999px', background:this.statusColor(t.status), flexShrink:0 } });
|
||||||
|
const raw = [
|
||||||
|
{ label:'1916', docs:[
|
||||||
|
{ title:'Feldpostbrief von der Westfront', date:'3. Februar 1916', status:'Transkribiert', people:['Herbert Cram','Clara Cram'] },
|
||||||
|
{ title:'Postkarte aus dem Lazarett Sedan', date:'17. April 1916', status:'Geprüft', people:['Herbert Cram','Marie Cram'] },
|
||||||
|
{ title:'Brief an die Mutter, Sütterlin', date:'9. Juni 1916', status:'Hochgeladen', people:['Herbert Cram','Eugenie de Gruyter'] },
|
||||||
|
{ title:'Glückwunsch zur Verlobung', date:'28. August 1916', status:'Transkribiert', people:['Clara Cram','Marie Cram'] },
|
||||||
|
{ title:'Feldpost — Absender unleserlich', date:'1916 (Monat unbekannt)', status:'Platzhalter', people:['Clara Cram'] },
|
||||||
|
{ title:'Weihnachtsgruß aus dem Schützengraben', date:'22. Dezember 1916', status:'Hochgeladen', people:['Herbert Cram','Clara Cram','Marie Cram'] },
|
||||||
|
]},
|
||||||
|
{ label:'1915', docs:[
|
||||||
|
{ title:'Erste Karte nach der Einberufung', date:'14. März 1915', status:'Geprüft', people:['Herbert Cram','Eugenie de Gruyter'] },
|
||||||
|
{ title:'Brief über das Leben in der Etappe', date:'30. Mai 1915', status:'Transkribiert', people:['Herbert Cram','Clara Cram'] },
|
||||||
|
{ title:'Postkarte mit Feldpoststempel', date:'11. September 1915', status:'Hochgeladen', people:['Marie Cram','Clara Cram'] },
|
||||||
|
{ title:'Brief an Eugenie, Kurrentschrift', date:'4. November 1915', status:'Platzhalter', people:['Eugenie de Gruyter','Herbert Cram'] },
|
||||||
|
{ title:'Adventsgruß an die Familie', date:'5. Dezember 1915', status:'Transkribiert', people:['Herbert Cram','Marie Cram','Clara Cram'] },
|
||||||
|
]},
|
||||||
|
{ label:'1914', docs:[
|
||||||
|
{ title:'Brief vor der Mobilmachung', date:'19. Juli 1914', status:'Geprüft', people:['Herbert Cram','Clara Cram'] },
|
||||||
|
{ title:'Karte vom Bahnhof Köln', date:'8. August 1914', status:'Transkribiert', people:['Herbert Cram','Eugenie de Gruyter'] },
|
||||||
|
{ title:'Erster Feldpostbrief aus Belgien', date:'27. September 1914', status:'Hochgeladen', people:['Herbert Cram','Marie Cram'] },
|
||||||
|
{ title:'Umschlag ohne Inhalt (Mappe A)', date:'1914 (undatiert)', status:'Platzhalter', people:['Clara Cram'] },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
return raw.map(g => ({ label:g.label, docs:g.docs.map(d => ({ ...d, people:this.avs(d.people), statusDot:{ display:'inline-block', width:7, height:7, borderRadius:'999px', background:this.statusColor(d.status), flexShrink:0 } })) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
tagChips(){
|
||||||
|
const tc = { sage:'#5a8a6a', sienna:'#a0522d', amber:'#c17a00', cobalt:'#3060b0' };
|
||||||
|
const raw = [
|
||||||
|
{ name:'Feldpost', color:tc.sienna },
|
||||||
|
{ name:'Erster Weltkrieg', color:tc.cobalt },
|
||||||
|
{ name:'Westfront', color:tc.amber },
|
||||||
|
];
|
||||||
|
return raw.map(t => ({ name:t.name, dot:{ display:'inline-block', width:8, height:8, borderRadius:'999px', background:t.color, flexShrink:0 } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
return {
|
||||||
|
groups: this.groups(),
|
||||||
|
tagChips: this.tagChips(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Archiv</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Dokumente</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Jüngste Eingänge, offene Aufgaben und der schnelle Weg zum nächsten Stück.</p>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">147 Dokumente · 38 Personen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- search bar -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); padding:16px; margin-bottom:16px; display:flex; align-items:center; gap:12px">
|
||||||
|
<div style="position:relative; flex:1">
|
||||||
|
<input placeholder="Titel, Personen, Tags durchsuchen…" style="width:100%; box-sizing:border-box; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:15px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
<button class="fa-link" style="display:flex; align-items:center; gap:8px; background:var(--c-surface); border:1px solid var(--c-line); padding:11px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px">Datum ↓</button>
|
||||||
|
<button class="fa-link" style="display:flex; align-items:center; gap:8px; background:var(--c-muted); border:1px solid var(--c-line); padding:11px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px"><img class="dgicon" src="assets/icons/Filter-MD.svg" style="width:15px; height:15px; opacity:.55">Filter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- resume -->
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; border:1px solid var(--c-line); border-left:3px solid var(--c-accent); background:var(--c-surface); padding:12px 16px; margin-bottom:20px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-2)">Weiter bei:</span>
|
||||||
|
<a href="#" class="fa-link" style="font-family:var(--font-serif); font-size:16px; font-style:italic; color:var(--c-ink); text-decoration:underline; text-decoration-color:var(--c-accent); text-underline-offset:3px; text-decoration-thickness:2px">Brief an Frieda aus dem Harz</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 320px; gap:18px; align-items:start">
|
||||||
|
<!-- recent documents -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); padding:24px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Zuletzt hinzugefügt</div>
|
||||||
|
<sc-for list="{{ docs }}" as="d" hint-placeholder-count="6">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; border-bottom:1px solid var(--c-line-2); padding:13px 0">
|
||||||
|
<a href="#" class="fa-link" style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink); text-decoration:none; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ d.title }}</a>
|
||||||
|
<div style="display:flex; align-items:center; gap:16px; flex-shrink:0">
|
||||||
|
<div style="display:flex; padding-left:6px">
|
||||||
|
<sc-for list="{{ d.people }}" as="p" hint-placeholder-count="2">
|
||||||
|
<span style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); width:128px; text-align:right">{{ d.date }}</span>
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px; width:118px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)"><span style="{{ d.statusDot }}"></span>{{ d.status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<p style="margin:16px 0 0; font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">147 Dokumente · 38 Personen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- right column -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:18px">
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); padding:6px">
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; border:1px dashed var(--c-line); padding:26px 18px; text-align:center">
|
||||||
|
<img class="dgicon" src="assets/icons/Upload-MD.svg" style="width:30px; height:30px; opacity:.4">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:14px; color:var(--c-ink-2)">Dateien hier ablegen oder klicken</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">PDF, JPG, PNG, TIFF bis 50 MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); padding:22px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Benötigt Metadaten</div>
|
||||||
|
<sc-for list="{{ needsMeta }}" as="m" hint-placeholder-count="3">
|
||||||
|
<div style="border-bottom:1px solid var(--c-line-2); padding:11px 0">
|
||||||
|
<a href="#" class="fa-link" style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink); text-decoration:none">{{ m }}</a>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<a href="#" class="fa-link" style="display:inline-block; margin-top:14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">Alle anzeigen →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mission control -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); padding:24px; margin-top:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:18px">Mission Control</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px">
|
||||||
|
<sc-for list="{{ mission }}" as="c" hint-placeholder-count="3">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:12px; border:1px solid var(--c-line); background:var(--c-muted); padding:16px">
|
||||||
|
<div>
|
||||||
|
<h3 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink); margin:0 0 8px">{{ c.heading }}</h3>
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:4px; border:1px solid var(--c-line); background:var(--c-accent-bg); padding:3px 10px; border-radius:999px; font-family:var(--font-sans); font-size:11px; font-weight:600; color:var(--c-ink)">{{ c.skill }}</span>
|
||||||
|
<p style="margin:8px 0 0; font-family:var(--font-sans); font-size:12px; font-weight:600; color:var(--c-ink-2)">{{ c.weekly }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<sc-for list="{{ c.items }}" as="it" hint-placeholder-count="3">
|
||||||
|
<a href="#" class="fa-link" style="display:flex; flex-direction:column; padding:8px 0; border-top:1px solid var(--c-line-2); text-decoration:none">
|
||||||
|
<span style="font-family:var(--font-serif); font-size:15px; color:var(--c-ink)">{{ it.title }}</span>
|
||||||
|
<span style="margin-top:2px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">{{ it.date }}</span>
|
||||||
|
</a>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name, a26:{ ...base, width:26, height:26, fontSize:10, marginLeft:-6, border:'2px solid var(--c-surface)' } };
|
||||||
|
}
|
||||||
|
avs(names){ return names.map(n => this.av(n)); }
|
||||||
|
|
||||||
|
docs(){
|
||||||
|
const sc = { 'Transkribiert':'#5a8a6a', 'In Arbeit':'#c17a00', 'Neu':'#607080' };
|
||||||
|
const raw = [
|
||||||
|
{ title:'Brief an Frieda aus dem Harz', date:'14. März 1923', status:'Transkribiert', people:['Frieda Rose','Anna Bauer'] },
|
||||||
|
{ title:'Postkarte aus Wien', date:'2. August 1924', status:'In Arbeit', people:['Karl Müller'] },
|
||||||
|
{ title:'Geschäftsbrief betr. Grundstück Mariahilf', date:'19. November 1925', status:'In Arbeit', people:['Otto Schmidt'] },
|
||||||
|
{ title:'Glückwunsch zum 60. Geburtstag', date:'22. Mai 1928', status:'Transkribiert', people:['Margarete Hoffmann'] },
|
||||||
|
{ title:'Brief aus der Lehrzeit', date:'8. Oktober 1930', status:'Neu', people:['Wilhelm Rose'] },
|
||||||
|
{ title:'Weihnachtsgruß an die Familie', date:'21. Dezember 1931', status:'Transkribiert', people:['Heinrich Rose'] },
|
||||||
|
];
|
||||||
|
return raw.map(d => ({ ...d, people:this.avs(d.people), statusDot:{ display:'inline-block', width:7, height:7, borderRadius:'999px', background:sc[d.status], flexShrink:0 } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
mission(){
|
||||||
|
return [
|
||||||
|
{ heading:'Segmentierung', skill:'Gut für Augen, die Seiten erkennen', weekly:'Diese Woche: 12 Seiten zugeordnet', items:[
|
||||||
|
{ title:'Konvolut Rose, Heft III', date:'2. Feb 1923' },
|
||||||
|
{ title:'Umschlag Mariahilf', date:'19. Nov 1925' },
|
||||||
|
{ title:'Mappe B — unsortiert', date:'' },
|
||||||
|
]},
|
||||||
|
{ heading:'Transkription', skill:'Gut für geübte Leser der Kurrentschrift', weekly:'Diese Woche: 7 Briefe begonnen', items:[
|
||||||
|
{ title:'Brief an Frieda aus dem Harz', date:'14. März 1923' },
|
||||||
|
{ title:'Postkarte aus Wien', date:'2. Aug 1924' },
|
||||||
|
{ title:'Geburtstagsbrief', date:'22. Mai 1928' },
|
||||||
|
]},
|
||||||
|
{ heading:'Zur Überprüfung', skill:'Gut für letzte Korrekturen', weekly:'', items:[
|
||||||
|
{ title:'Brief aus der Lehrzeit', date:'8. Okt 1930' },
|
||||||
|
{ title:'Weihnachtsgruß', date:'21. Dez 1931' },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
return {
|
||||||
|
docs: this.docs(),
|
||||||
|
needsMeta: ['Brief ohne Titel (Mappe B)', 'Postkarte — Absender unbekannt', 'Umschlag, 1924'],
|
||||||
|
mission: this.mission(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="zeitstrahl" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- back link -->
|
||||||
|
<a href="Zeitstrahl.dc.html" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none; margin-bottom:24px">← Zurück zum Zeitstrahl</a>
|
||||||
|
|
||||||
|
<!-- PageHeader (§3) -->
|
||||||
|
<div style="margin-bottom:30px; border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">{{ eyebrow }}</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">{{ title }}</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">{{ lede }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:2fr 1fr; gap:24px; align-items:start">
|
||||||
|
|
||||||
|
<!-- ─── MAIN column ─── -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:24px">
|
||||||
|
|
||||||
|
<!-- Wann & Was -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Wann & Was</h2>
|
||||||
|
|
||||||
|
<!-- Titel (with error state on a missing-title field) -->
|
||||||
|
<label style="display:block; margin-bottom:22px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel *</span>
|
||||||
|
<input value="{{ f.title }}" placeholder="z.B. Hochzeit von Hannemarie Cram" style="{{ titleInputStyle }}">
|
||||||
|
<sc-if value="{{ titleError }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex; align-items:center; gap:7px; margin-top:7px">
|
||||||
|
<img class="dgicon" src="assets/icons/Check-MD.svg" style="width:14px; height:14px; opacity:.7">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.04em; color:var(--c-danger)">{{ titleError }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Ereignistyp — segmented control (§6) -->
|
||||||
|
<div style="margin-bottom:22px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Ereignistyp</div>
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line); border-radius:2px; overflow:hidden">
|
||||||
|
<sc-for list="{{ typeSegments }}" as="seg" hint-placeholder-count="2">
|
||||||
|
<span class="fa-link" style="{{ seg.style }}">{{ seg.label }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Datum + Genauigkeit hint -->
|
||||||
|
<div>
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Datum</span>
|
||||||
|
<div style="position:relative; max-width:280px">
|
||||||
|
<input value="{{ f.date }}" placeholder="TT.MM.JJJJ" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-top:7px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">
|
||||||
|
<span>Genauigkeit:</span>
|
||||||
|
<sc-for list="{{ precisionHints }}" as="p" hint-placeholder-count="3">
|
||||||
|
<span style="{{ p.style }}">{{ p.label }}</span>
|
||||||
|
<sc-if value="{{ p.sep }}" hint-placeholder-val="{{ true }}"><span>·</span></sc-if>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beschreibung -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Beschreibung</h2>
|
||||||
|
<label style="display:block">
|
||||||
|
<span class="sr-only" style="position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0)">Beschreibung des Ereignisses</span>
|
||||||
|
<textarea rows="5" placeholder="Was geschah an diesem Tag? Hintergrund, Quellen, Beteiligte…" style="width:100%; resize:vertical; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; line-height:1.7; color:var(--c-ink); outline:none; border-radius:2px">{{ f.description }}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── SIDEBAR ─── -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:24px">
|
||||||
|
|
||||||
|
<!-- Beteiligte Personen -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Beteiligte Personen</h2>
|
||||||
|
|
||||||
|
<sc-if value="{{ hasPersons }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:14px">
|
||||||
|
<sc-for list="{{ persons }}" as="p" hint-placeholder-count="3">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:8px; background:var(--c-muted); border:1px solid var(--c-line); border-radius:2px; padding:4px 6px 4px 4px">
|
||||||
|
<span style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:14px; color:var(--c-ink)">{{ p.name }}</span>
|
||||||
|
<a href="#" title="Person entfernen" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; font-family:var(--font-sans); font-size:15px; font-weight:700; color:var(--c-ink-3); text-decoration:none">×</a>
|
||||||
|
</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ noPersons }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="border:1px dashed var(--c-line); border-radius:2px; padding:24px 16px; text-align:center; margin-bottom:14px">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink); margin-bottom:4px">Noch keine Person verknüpft.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">Beginnen Sie zu tippen…</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- add typeahead -->
|
||||||
|
<div style="position:relative">
|
||||||
|
<input placeholder="Person suchen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verknüpfte Briefe -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Verknüpfte Briefe</h2>
|
||||||
|
|
||||||
|
<sc-if value="{{ hasDocuments }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:14px">
|
||||||
|
<sc-for list="{{ documents }}" as="d" hint-placeholder-count="2">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; border:1px solid var(--c-line); border-radius:2px; padding:12px">
|
||||||
|
<span style="width:38px; height:38px; flex-shrink:0; background:var(--c-muted); border:1px solid var(--c-line-2); border-radius:2px; display:flex; align-items:center; justify-content:center">{{ d.iconEl }}</span>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:15px; color:var(--c-ink); line-height:1.3">{{ d.title }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-top:3px">{{ d.meta }}</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" title="Brief entfernen" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-width:44px; min-height:44px; font-family:var(--font-sans); font-size:18px; font-weight:700; color:var(--c-ink-3); text-decoration:none">×</a>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ noDocuments }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="border:1px dashed var(--c-line); border-radius:2px; padding:24px 16px; text-align:center; margin-bottom:14px">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink); margin-bottom:4px">Noch kein Brief verknüpft.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">Verknüpfen Sie einen Brief aus dem Archiv…</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- add typeahead -->
|
||||||
|
<div style="position:relative">
|
||||||
|
<input placeholder="Brief suchen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Sticky save bar ─── -->
|
||||||
|
<div style="position:sticky; bottom:0; z-index:20; margin-top:24px; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:0 -2px 8px rgba(0,0,0,.06); border-radius:2px; padding:16px 24px">
|
||||||
|
<div style="display:flex; align-items:center; gap:18px">
|
||||||
|
<sc-if value="{{ isEdit }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Löschen</a>
|
||||||
|
</sc-if>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">Ereignisse erscheinen im Zeitstrahl.</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:14px">
|
||||||
|
<a href="{{ discardHref }}" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; text-decoration:none; border-radius:2px">Abbrechen</a>
|
||||||
|
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1300},"variant":{"editor":"enum","options":["neu","bearbeiten"],"default":"bearbeiten","tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSegments(activeLabel){
|
||||||
|
const labels = ['Persönlich','Historisch'];
|
||||||
|
return labels.map((label, i) => {
|
||||||
|
const active = label === activeLabel;
|
||||||
|
const style = {
|
||||||
|
fontFamily:'var(--font-sans)', fontSize:12, fontWeight:700, letterSpacing:'.08em',
|
||||||
|
textTransform:'uppercase', padding:'10px 16px', cursor:'pointer',
|
||||||
|
background: active ? 'var(--c-primary)' : 'var(--c-surface)',
|
||||||
|
color: active ? 'var(--c-primary-fg)' : 'var(--c-ink-2)',
|
||||||
|
};
|
||||||
|
if (i > 0) style.borderLeft = '1px solid var(--c-line)';
|
||||||
|
return { label, style };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
precisionHints(activeLabel){
|
||||||
|
const labels = ['Tag','Monat','Jahr'];
|
||||||
|
return labels.map((label, i) => ({
|
||||||
|
label,
|
||||||
|
sep: i < labels.length - 1,
|
||||||
|
style: { color: label === activeLabel ? 'var(--c-ink-2)' : 'var(--c-ink-3)', fontWeight: label === activeLabel ? 700 : 400 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const variant = this.props.variant || 'bearbeiten';
|
||||||
|
const isEdit = variant === 'bearbeiten';
|
||||||
|
|
||||||
|
const editing = {
|
||||||
|
title:'Hochzeit von Hannemarie Cram',
|
||||||
|
type:'Persönlich',
|
||||||
|
precision:'Tag',
|
||||||
|
date:'12.05.1945',
|
||||||
|
description:'Hannemarie Cram heiratet mitten im Krieg, wenige Tage nach der Kapitulation. Die Trauung findet im engsten Familienkreis statt — Herbert ist noch an der Front vermisst. Mehrere Glückwunschbriefe aus dem Mai 1945 nehmen auf diesen Tag Bezug.',
|
||||||
|
};
|
||||||
|
const blank = {
|
||||||
|
title:'',
|
||||||
|
type:'Persönlich',
|
||||||
|
precision:'Tag',
|
||||||
|
date:'',
|
||||||
|
description:'',
|
||||||
|
};
|
||||||
|
const f = isEdit ? editing : blank;
|
||||||
|
|
||||||
|
// Error state demo: the Titel field shows a danger border + danger message
|
||||||
|
// (Check/warning icon, never an emoji). On `neu` the empty title is flagged.
|
||||||
|
const titleError = isEdit ? '' : 'Bitte einen Titel eingeben.';
|
||||||
|
const titleInputStyle = {
|
||||||
|
width:'100%',
|
||||||
|
border: '1px solid ' + (titleError ? 'var(--c-danger)' : 'var(--c-line)'),
|
||||||
|
background:'var(--c-surface)', padding:'11px 14px',
|
||||||
|
fontFamily:'var(--font-serif)', fontSize:16, color:'var(--c-ink)',
|
||||||
|
outline:'none', borderRadius:'2px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const personNames = isEdit
|
||||||
|
? ['Hannemarie Cram', 'Clara Cram', 'Marie Cram']
|
||||||
|
: [];
|
||||||
|
const persons = personNames.map(n => this.av(n));
|
||||||
|
|
||||||
|
const rawDocs = isEdit
|
||||||
|
? [
|
||||||
|
{ title:'C-0512 – 14. Mai 1945 – Glückwunsch zur Hochzeit', meta:'14.05.1945 · von Eugenie de Gruyter an Hannemarie Cram', kind:'letter' },
|
||||||
|
{ title:'C-0518 – 20. Mai 1945 – Hochzeitsbericht', meta:'20.05.1945 · von Clara Cram an Marie Cram', kind:'letter' },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const documents = rawDocs.map(d => ({
|
||||||
|
...d,
|
||||||
|
iconEl: React.createElement('img', {
|
||||||
|
className:'dgicon',
|
||||||
|
src: d.kind === 'folder' ? 'assets/icons/Folder-MD.svg' : 'assets/icons/Mail-MD.svg',
|
||||||
|
style:{ width:18, height:18, opacity:.5 },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEdit,
|
||||||
|
eyebrow:'Chronik',
|
||||||
|
title: isEdit ? f.title : 'Neues Ereignis',
|
||||||
|
lede: isEdit
|
||||||
|
? 'Pflegen Sie Datum, Beteiligte und verknüpfte Briefe dieses Ereignisses.'
|
||||||
|
: 'Verankern Sie einen Moment der Familiengeschichte im Zeitstrahl.',
|
||||||
|
f,
|
||||||
|
titleError,
|
||||||
|
titleInputStyle,
|
||||||
|
typeSegments: this.typeSegments(f.type),
|
||||||
|
precisionHints: this.precisionHints(f.precision),
|
||||||
|
persons, hasPersons: persons.length > 0, noPersons: persons.length === 0,
|
||||||
|
documents, hasDocuments: documents.length > 0, noDocuments: documents.length === 0,
|
||||||
|
discardHref: isEdit ? 'Zeitstrahl.dc.html' : 'Zeitstrahl.dc.html',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="geschichten" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 110px">
|
||||||
|
|
||||||
|
<!-- back link -->
|
||||||
|
<a href="{{ backHref }}" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none; margin-bottom:24px">← {{ backLabel }}</a>
|
||||||
|
|
||||||
|
<!-- PageHeader (§3) -->
|
||||||
|
<div style="margin-bottom:30px; border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">{{ eyebrow }}</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">{{ title }}</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">{{ lede }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════════ VARIANT: neu (type picker) ════════════════════ -->
|
||||||
|
<sc-if value="{{ isNeu }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="max-width:780px">
|
||||||
|
|
||||||
|
<!-- segmented control (§6) — Lesereise / Sammlung -->
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:10px">Art der Geschichte</div>
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line); border-radius:2px; overflow:hidden; margin-bottom:28px">
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:10px 22px; cursor:pointer">Lesereise</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 22px; border-left:1px solid var(--c-line); cursor:pointer">Sammlung</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- two explanatory cards -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:28px">
|
||||||
|
|
||||||
|
<!-- Lesereise card (selected) -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:18px; height:18px; opacity:.4">
|
||||||
|
<h2 style="font-family:var(--font-serif); font-size:21px; font-weight:700; color:var(--c-ink); margin:0">Lesereise</h2>
|
||||||
|
</div>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:16px; line-height:1.65; color:var(--c-ink-2); margin:0 0 14px">Eine geführte Abfolge von Briefen in fester Reihenfolge — die Lesenden gehen Schritt für Schritt durch den Briefwechsel. Zwischen den Dokumenten setzen Sie eigene Anmerkungen, die den Bogen spannen.</p>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); line-height:1.6">Ideal für: <span style="font-style:normal; color:var(--c-ink-2)">die Feldpost Herberts 1914–1918 chronologisch erzählt.</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sammlung card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px">
|
||||||
|
<img class="dgicon" src="assets/icons/Library-MD.svg" style="width:18px; height:18px; opacity:.4">
|
||||||
|
<h2 style="font-family:var(--font-serif); font-size:21px; font-weight:700; color:var(--c-ink); margin:0">Sammlung</h2>
|
||||||
|
</div>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:16px; line-height:1.65; color:var(--c-ink-2); margin:0 0 14px">Ein frei geschriebener Prosatext mit Überschriften und Absätzen, in den Sie einzelne Briefe als Belege einbetten. Sie führen die Feder — die Dokumente stützen Ihre Erzählung.</p>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); line-height:1.6">Ideal für: <span style="font-style:normal; color:var(--c-ink-2)">ein Porträt der drei Kinder über mehrere Jahrzehnte.</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- primary action -->
|
||||||
|
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 24px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Weiter</button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ════════════════════ VARIANT: geschichte (prose / Sammlung editor) ════════════════════ -->
|
||||||
|
<sc-if value="{{ isGeschichte }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:grid; grid-template-columns:2fr 1fr; gap:24px; align-items:start">
|
||||||
|
|
||||||
|
<!-- MAIN: editor card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:0; overflow:hidden">
|
||||||
|
|
||||||
|
<!-- title field -->
|
||||||
|
<div style="padding:24px 28px 0">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel</span>
|
||||||
|
<input value="Die Kinder: Briefe an und über Hannemarie, Clara-Eugenie und Kurt-Georg" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:22px; font-weight:700; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- toolbar (§6 segment styling) -->
|
||||||
|
<div style="padding:18px 28px 0">
|
||||||
|
<div style="display:inline-flex; flex-wrap:wrap; border:1px solid var(--c-line); border-radius:2px; overflow:hidden">
|
||||||
|
<sc-for list="{{ toolbarButtons }}" as="t" hint-placeholder-count="6">
|
||||||
|
<span class="fa-link" title="{{ t.title }}" style="{{ t.style }}">{{ t.iconEl }}<span>{{ t.label }}</span></span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- content editing area (Tinos prose) -->
|
||||||
|
<div style="padding:18px 28px 28px">
|
||||||
|
<div style="border:1px solid var(--c-line); border-radius:2px; background:var(--c-surface); padding:24px 26px; min-height:340px">
|
||||||
|
<p style="font-family:var(--font-serif); font-size:17px; line-height:1.75; color:var(--c-ink); margin:0 0 18px">Herbert und Clara Cram hatten drei Kinder: <b>Hannemarie</b>, <b>Clara-Eugenie</b> und <b>Kurt-Georg</b>. Die Briefe dieser Sammlung drehen sich um sie — als Kleinkinder, als Schüler, als junge Erwachsene im Zweiten Weltkrieg.</p>
|
||||||
|
|
||||||
|
<h3 style="font-family:var(--font-serif); font-size:22px; font-weight:700; color:var(--c-ink); margin:26px 0 12px">Die Töchter im Krieg</h3>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:17px; line-height:1.75; color:var(--c-ink); margin:0 0 18px">Clara-Eugenie dient bei der Flak, während <i>Hannemarie</i> 1945 mitten im Krieg heiratet. Durch die Briefe der Eltern und Großeltern entsteht das Bild einer ganzen Generation, die im Schatten der großen Geschichte aufwuchs:</p>
|
||||||
|
|
||||||
|
<blockquote style="border-left:3px solid var(--c-accent); padding-left:20px; margin:0 0 18px">
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:17px; line-height:1.7; color:var(--c-ink-2); margin:0">„Schreibt mir, sobald Ihr könnt — die Tage sind lang ohne ein Wort von Euch."</p>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<p style="font-family:var(--font-serif); font-size:17px; line-height:1.75; color:var(--c-ink); margin:0">Clara schreibt an ihre Kinder, die Großeltern schreiben über sie, Freunde erkundigen sich nach ihnen<span style="display:inline-block; width:1px; height:20px; background:var(--c-ink); margin-left:2px; vertical-align:-4px"></span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SIDEBAR: status + persons + cover -->
|
||||||
|
<aside style="display:flex; flex-direction:column; gap:20px">
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:20px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 12px">Status</h2>
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line); border-radius:2px; overflow:hidden; margin-bottom:12px">
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 13px; cursor:pointer">Entwurf</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 13px; border-left:1px solid var(--c-line); cursor:pointer">In Arbeit</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 13px; border-left:1px solid var(--c-line); cursor:pointer">Veröffentlicht</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:7px">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:#5a8a6a"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">Veröffentlicht — für alle sichtbar</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Personen -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:20px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 6px">Personen</h2>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:0 0 14px">Verknüpfte Personen erscheinen auf deren Profilseiten.</p>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:14px">
|
||||||
|
<sc-for list="{{ persons }}" as="p" hint-placeholder-count="4">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:8px; background:var(--c-muted); border:1px solid var(--c-line); border-radius:2px; padding:4px 6px 4px 4px">
|
||||||
|
<span style="{{ p.av.a26 }}">{{ p.av.initials }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:14px; color:var(--c-ink)">{{ p.name }}</span>
|
||||||
|
<a href="#" class="fa-link" title="Entfernen" style="display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; font-family:var(--font-sans); font-size:15px; font-weight:700; color:var(--c-ink-3); text-decoration:none">×</a>
|
||||||
|
</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input placeholder="Person hinzufügen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:10px 38px 10px 13px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Titelbild / Intro -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:20px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 6px">Titelbild / Intro</h2>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:0 0 12px">Ein kurzer einleitender Text für die Übersicht.</p>
|
||||||
|
<textarea rows="4" style="width:100%; resize:vertical; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; line-height:1.6; color:var(--c-ink); outline:none; border-radius:2px">Ein Porträt der drei Cram-Kinder durch die Briefe ihrer Eltern und Großeltern.</textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ════════════════════ VARIANT: lesereise (journey editor) ════════════════════ -->
|
||||||
|
<sc-if value="{{ isLesereise }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="display:grid; grid-template-columns:2fr 1fr; gap:24px; align-items:start">
|
||||||
|
|
||||||
|
<!-- MAIN: title + intro + journey item list -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:18px">
|
||||||
|
|
||||||
|
<!-- title -->
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel</span>
|
||||||
|
<input value="Feldpost: Herbert an der Westfront 1914–1918" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:22px; font-weight:700; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- intro -->
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Einleitung</span>
|
||||||
|
<textarea rows="3" style="width:100%; resize:vertical; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; line-height:1.6; color:var(--c-ink); outline:none; border-radius:2px">Herbert Cram schreibt aus den Schützengräben nach Hause — an seine Frau Clara und seine Mutter Marie. Über 440 Feldpostbriefe in vier Jahren, allein 349 im Jahr 1918.</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- station list -->
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin-top:6px">Stationen der Lesereise</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px">
|
||||||
|
<sc-for list="{{ journeyItems }}" as="it" hint-placeholder-count="5">
|
||||||
|
|
||||||
|
<!-- letter row -->
|
||||||
|
<sc-if value="{{ it.isLetter }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="{{ it.rowStyle }}">
|
||||||
|
<div style="display:flex; align-items:center; gap:14px; padding:14px 16px">
|
||||||
|
<!-- drag handle (View-More icon) -->
|
||||||
|
<span class="fa-link" title="Zum Verschieben ziehen" style="display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; flex-shrink:0; cursor:grab; color:var(--c-ink-3)">
|
||||||
|
<img class="dgicon" src="assets/icons/View-More-MD.svg" style="width:18px; height:18px; opacity:.4">
|
||||||
|
</span>
|
||||||
|
<!-- 40px Mail tile -->
|
||||||
|
<span style="width:40px; height:40px; flex-shrink:0; background:var(--c-muted); border:1px solid var(--c-line-2); display:flex; align-items:center; justify-content:center; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:18px; height:18px; opacity:.5">
|
||||||
|
</span>
|
||||||
|
<!-- title + meta -->
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink); line-height:1.3">{{ it.title }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-top:3px">{{ it.meta }}</div>
|
||||||
|
</div>
|
||||||
|
<!-- remove -->
|
||||||
|
<a href="#" class="fa-link" title="Aus der Lesereise entfernen" style="display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; flex-shrink:0; font-family:var(--font-sans); font-size:14px; font-weight:700; color:var(--c-ink-3); text-decoration:none">×</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- note sub-field -->
|
||||||
|
<sc-if value="{{ it.hasNote }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:0 16px 14px 74px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Anmerkung</div>
|
||||||
|
<textarea rows="2" style="width:100%; resize:vertical; border:1px solid var(--c-line); background:var(--c-accent-bg); padding:10px 13px; font-family:var(--font-serif); font-style:italic; font-size:15px; line-height:1.6; color:var(--c-ink-2); outline:none; border-radius:2px">{{ it.note }}</textarea>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ it.noNote }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:0 16px 14px 74px">
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">+ Anmerkung hinzufügen</a>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- add-document row -->
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:12px; border:1px dashed var(--c-line); border-radius:2px; padding:16px 18px; min-height:44px; text-decoration:none">
|
||||||
|
<img class="dgicon" src="assets/icons/Folder-MD.svg" style="width:18px; height:18px; opacity:.4">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">Dokument hinzufügen</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- empty-state variant note -->
|
||||||
|
<div style="border:1px dashed var(--c-line); border-radius:2px; padding:18px 22px; background:var(--c-muted)">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Variante: leere Lesereise</div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink); margin-bottom:4px">Noch keine Briefe in dieser Lesereise.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Fügen Sie über „Dokument hinzufügen" den ersten Brief hinzu…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SIDEBAR: same as story -->
|
||||||
|
<aside style="display:flex; flex-direction:column; gap:20px">
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:20px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 12px">Status</h2>
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line); border-radius:2px; overflow:hidden; margin-bottom:12px">
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 13px; cursor:pointer">Entwurf</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 13px; border-left:1px solid var(--c-line); cursor:pointer">In Arbeit</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 13px; border-left:1px solid var(--c-line); cursor:pointer">Veröffentlicht</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:7px">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:#c17a00"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">In Arbeit — noch nicht öffentlich</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Personen -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:20px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 6px">Personen</h2>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:0 0 14px">Verknüpfte Personen erscheinen auf deren Profilseiten.</p>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom:14px">
|
||||||
|
<sc-for list="{{ journeyPersons }}" as="p" hint-placeholder-count="3">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:8px; background:var(--c-muted); border:1px solid var(--c-line); border-radius:2px; padding:4px 6px 4px 4px">
|
||||||
|
<span style="{{ p.av.a26 }}">{{ p.av.initials }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:14px; color:var(--c-ink)">{{ p.name }}</span>
|
||||||
|
<a href="#" class="fa-link" title="Entfernen" style="display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; font-family:var(--font-sans); font-size:15px; font-weight:700; color:var(--c-ink-3); text-decoration:none">×</a>
|
||||||
|
</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input placeholder="Person hinzufügen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:10px 38px 10px 13px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Titelbild / Intro -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:20px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 6px">Titelbild / Intro</h2>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:0 0 12px">Ein kurzer einleitender Text für die Übersicht.</p>
|
||||||
|
<textarea rows="4" style="width:100%; resize:vertical; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; line-height:1.6; color:var(--c-ink); outline:none; border-radius:2px">Vier Kriegsjahre in Feldpostbriefen — von der Mobilmachung bis zum Waffenstillstand.</textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ════════════════════ Sticky save bar (geschichte + lesereise) ════════════════════ -->
|
||||||
|
<sc-if value="{{ showSaveBar }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:sticky; bottom:0; margin-top:28px; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:0 -2px 8px rgba(0,0,0,.06); border-radius:2px; padding:16px 24px">
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Löschen</a>
|
||||||
|
<div style="display:flex; align-items:center; gap:14px">
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Vorschau</button>
|
||||||
|
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1500},"variant":{"editor":"enum","options":["neu","geschichte","lesereise"],"default":"geschichte","tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar segment styled like a §6 segmented control. Active = navy fill.
|
||||||
|
toolbar(){
|
||||||
|
const items = [
|
||||||
|
{ label:'Fett', icon:null, active:false },
|
||||||
|
{ label:'Kursiv', icon:null, active:false },
|
||||||
|
{ label:'Überschrift', icon:null, active:true },
|
||||||
|
{ label:'Zitat', icon:'assets/icons/Chat-MD.svg', active:false },
|
||||||
|
{ label:'Liste', icon:null, active:false },
|
||||||
|
{ label:'Link', icon:'assets/icons/Globe-MD.svg',active:false },
|
||||||
|
];
|
||||||
|
return items.map((it, i) => {
|
||||||
|
const style = {
|
||||||
|
display:'inline-flex', alignItems:'center', gap:6,
|
||||||
|
fontFamily:'var(--font-sans)', fontSize:12, fontWeight:700, letterSpacing:'.08em',
|
||||||
|
textTransform:'uppercase', padding:'9px 14px', cursor:'pointer', whiteSpace:'nowrap',
|
||||||
|
background: it.active ? 'var(--c-primary)' : 'var(--c-surface)',
|
||||||
|
color: it.active ? 'var(--c-primary-fg)' : 'var(--c-ink-2)',
|
||||||
|
};
|
||||||
|
if (i > 0) style.borderLeft = '1px solid var(--c-line)';
|
||||||
|
const iconEl = it.icon
|
||||||
|
? React.createElement('img', { className:'dgicon', src:it.icon, style:{ width:14, height:14, opacity: it.active ? .9 : .4, filter: it.active ? 'invert(1) brightness(1.6)' : undefined } })
|
||||||
|
: null;
|
||||||
|
return { ...it, title:it.label, style, iconEl };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const variant = this.props.variant || 'geschichte';
|
||||||
|
const isNeu = variant === 'neu';
|
||||||
|
const isGeschichte = variant === 'geschichte';
|
||||||
|
const isLesereise = variant === 'lesereise';
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
neu: { eyebrow:'Geschichten', title:'Neue Geschichte', lede:'Wählen Sie, wie Sie die Briefe Ihrer Familie erzählen möchten.', back:'Zurück zu Geschichten', backHref:'Geschichten.dc.html' },
|
||||||
|
geschichte: { eyebrow:'Geschichte bearbeiten', title:'Die Kinder', lede:'Schreiben Sie freien Prosatext und betten Sie Briefe als Belege ein.', back:'Zurück zur Geschichte', backHref:'Geschichte.dc.html' },
|
||||||
|
lesereise: { eyebrow:'Lesereise bearbeiten', title:'Feldpost 1914–1918',lede:'Ordnen Sie die Briefe in eine geführte Abfolge und ergänzen Sie Anmerkungen.', back:'Zurück zur Lesereise', backHref:'Geschichte.dc.html?variant=Lesereise' },
|
||||||
|
}[variant];
|
||||||
|
|
||||||
|
// Sammlung persons (story editor)
|
||||||
|
const persons = ['Hannemarie Cram','Clara-Eugenie Cram','Kurt-Georg Cram','Clara Cram']
|
||||||
|
.map(n => ({ name:n, av:this.av(n) }));
|
||||||
|
|
||||||
|
// Lesereise persons
|
||||||
|
const journeyPersons = ['Herbert Cram','Clara Cram','Marie Cram']
|
||||||
|
.map(n => ({ name:n, av:this.av(n) }));
|
||||||
|
|
||||||
|
// Journey items — chronological Feldpost. One row in a COLOR-ONLY drag/active
|
||||||
|
// state (§8): bg var(--c-muted) + 3px mint LEFT rule + opacity. NO transform.
|
||||||
|
const rawItems = [
|
||||||
|
{ title:'H-0366 – 31. Oktober 1915 – Feldpost', meta:'31.10.1915 · von Herbert Cram an Clara Cram', note:'Der erste erhaltene Brief aus dem Feld — Herbert berichtet vom Quartier hinter der Front.', dragging:false },
|
||||||
|
{ title:'H-0370 – 29. März 1916 – Feldpost', meta:'29.03.1916 · von Herbert Cram an Clara Cram', note:'', dragging:true },
|
||||||
|
{ title:'H-0023 – 6. Juli 1916 – Feldpost', meta:'06.07.1916 · von Herbert Cram an Marie Cram', note:'', dragging:false },
|
||||||
|
{ title:'H-0015 – 21. Juli 1916 – Feldpost', meta:'21.07.1916 · von Herbert Cram an Marie Cram', note:'Zehn Minuten vor dem Gang in Stellung geschrieben — einer der eindringlichsten Briefe des Archivs.', dragging:false },
|
||||||
|
{ title:'H-0016 – 22. Juli 1916 – Feldpost', meta:'22.07.1916 · von Herbert Cram an Marie Cram', note:'', dragging:false },
|
||||||
|
];
|
||||||
|
const journeyItems = rawItems.map(it => {
|
||||||
|
const base = 'background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px';
|
||||||
|
const drag = 'background:var(--c-muted); border:1px solid var(--c-line); border-left:3px solid var(--c-accent); border-radius:2px; opacity:.85';
|
||||||
|
return {
|
||||||
|
...it,
|
||||||
|
isLetter:true,
|
||||||
|
hasNote: it.note.length > 0,
|
||||||
|
noNote: it.note.length === 0,
|
||||||
|
rowStyle: it.dragging ? drag : base,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isNeu, isGeschichte, isLesereise,
|
||||||
|
showSaveBar: isGeschichte || isLesereise,
|
||||||
|
eyebrow:header.eyebrow, title:header.title, lede:header.lede,
|
||||||
|
backLabel:header.back, backHref:header.backHref,
|
||||||
|
toolbarButtons:this.toolbar(),
|
||||||
|
persons, journeyPersons, journeyItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="geschichten" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:32px 32px 90px">
|
||||||
|
|
||||||
|
<a href="Geschichten.dc.html" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none; margin-bottom:24px">← Zurück zu Geschichten</a>
|
||||||
|
|
||||||
|
<article style="max-width:880px; margin:0 auto; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); padding:48px 56px 56px">
|
||||||
|
|
||||||
|
<!-- header -->
|
||||||
|
<span style="display:inline-block; background:var(--c-accent-bg); border:1px solid var(--c-accent); color:var(--c-ink); padding:4px 11px; border-radius:4px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; margin-bottom:18px">{{ badge }}</span>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-size:38px; font-weight:700; line-height:1.15; color:var(--c-ink); margin:0 0 22px">{{ title }}</h1>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; padding-bottom:26px; border-bottom:1px solid var(--c-line)">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px">
|
||||||
|
<span style="{{ author.a40 }}">{{ author.initials }}</span>
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:14px; font-weight:600; color:var(--c-ink)">{{ author.name }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-top:2px">{{ bylineMeta }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:14px">
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); border:1px solid var(--c-line); padding:9px 16px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px">Bearbeiten</button>
|
||||||
|
<a href="#" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Löschen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- intro -->
|
||||||
|
<p style="{{ introStyle }}">{{ intro }}</p>
|
||||||
|
|
||||||
|
<sc-if value="{{ sectionHeading }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin:36px 0 16px; padding-top:28px; border-top:1px solid var(--c-line)">{{ sectionHeading }}</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- blocks -->
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<sc-for list="{{ blocks }}" as="b" hint-placeholder-count="6">
|
||||||
|
<sc-if value="{{ b.isNarration }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="border-left:3px solid var(--c-accent); padding-left:22px; margin:30px 0">
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:18px; line-height:1.7; color:var(--c-ink-2); margin:0">{{ b.text }}</p>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ b.isLetter }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:16px; background:var(--c-surface); border:1px solid var(--c-line); padding:14px 18px; text-decoration:none; margin:10px 0">
|
||||||
|
<span style="width:40px; height:40px; flex-shrink:0; background:var(--c-muted); border:1px solid var(--c-line-2); display:flex; align-items:center; justify-content:center">{{ b.iconEl }}</span>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink); line-height:1.3">{{ b.title }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-top:3px">{{ b.meta }}</div>
|
||||||
|
</div>
|
||||||
|
<img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:18px; height:18px; opacity:.4; flex-shrink:0">
|
||||||
|
</a>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ b.isNote }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="background:var(--c-accent-bg); border-left:3px solid var(--c-accent); padding:14px 18px; margin:2px 0 14px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Anmerkung</div>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:15px; line-height:1.6; color:var(--c-ink-2); margin:0">{{ b.text }}</p>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1400},"variant":{"editor":"enum","options":["Lesereise","Sammlung"],"default":"Lesereise","tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name, a40:{ ...base, width:40, height:40, fontSize:14 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
story(){
|
||||||
|
const variant = this.props.variant || 'Lesereise';
|
||||||
|
if (variant === 'Sammlung'){
|
||||||
|
return {
|
||||||
|
badge:'Sammlung',
|
||||||
|
title:'Die Kinder: Briefe an und über Hannemarie, Clara-Eugenie und Kurt-Georg',
|
||||||
|
dateVerb:'veröffentlicht am', date:'11. Juni 2026', introItalic:false,
|
||||||
|
intro:'Herbert und Clara Cram hatten drei Kinder: Hannemarie, Clara-Eugenie und Kurt-Georg. Die Briefe dieser Sammlung drehen sich um sie – als Kleinkinder, als Schüler, als junge Erwachsene im Zweiten Weltkrieg. Clara schreibt an ihre Kinder, die Großeltern schreiben über sie, Freunde erkundigen sich nach ihnen. Clara-Eugenie dient bei der Flak, Walter kämpft an der Front, Hannemarie heiratet 1945 mitten im Krieg. Durch die Briefe der Eltern und Großeltern entsteht das Bild einer ganzen Generation, die im Schatten der großen Geschichte aufwuchs.',
|
||||||
|
sectionHeading:'Erwähnte Dokumente',
|
||||||
|
blocks:[
|
||||||
|
{ kind:'letter', title:'W-0780 – 17. April 1894 – Ruhrort?', meta:'17.04.1894 · von Ella Dieckmann an Geschwister de Gruyter' },
|
||||||
|
{ kind:'letter', title:'Eu-0078 – 5. Dezember 1895 – Ruhrort', meta:'05.12.1895 · von Eugenie de Gruyter an Walter de Gruyter' },
|
||||||
|
{ kind:'letter', title:'Eu-0087 – 23. Dezember 1895 – Oberhausen', meta:'23.12.1895 · von Eugenie de Gruyter an Walter de Gruyter' },
|
||||||
|
{ kind:'letter', title:'Eu-0092 – 11. Januar 1896 – Hückeswagen', meta:'11.01.1896 · von Eugenie de Gruyter an Walter de Gruyter' },
|
||||||
|
{ kind:'letter', title:'Eu-0101 – 3. März 1896 – Ruhrort', meta:'03.03.1896 · von Eugenie de Gruyter an Walter de Gruyter' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
badge:'Lesereise',
|
||||||
|
title:'Feldpost: Herbert an der Westfront 1914–1918',
|
||||||
|
dateVerb:'zusammengestellt am', date:'11. Juni 2026', introItalic:true,
|
||||||
|
intro:'Herbert Cram schreibt aus den Schützengräben nach Hause – an seine Frau Clara und seine Mutter Marie. Über 440 Feldpostbriefe in vier Jahren, allein 349 im Jahr 1918. Zwischen Pflichterfüllung und Sehnsucht: »In 10 Minuten gehe ich in Stellung zum ehrenvollen, heissen und schweren Kampfe.« Und kurz vor dem Waffenstillstand: »ich bin tatsächlich zu keiner Arbeit mehr zu brauchen, weil Du mir Tag u Nacht im Kopf herumspukst.«',
|
||||||
|
sectionHeading:null,
|
||||||
|
blocks:[
|
||||||
|
{ kind:'narration', text:'Als die Mobilmachung am 1. August 1914 begann, wurde Herbert Cram eingezogen. Er war der Sohn von John James Cram aus Monterrey – aufgewachsen ohne den Vater, der in Mexiko lebte. Jetzt schrieb er selbst Feldpostbriefe. Die folgende Korrespondenz umfasst vier Kriegsjahre.' },
|
||||||
|
{ kind:'letter', title:'H-0366 – 31. Oktober 1915 – Feldpost', meta:'31.10.1915 · von Herbert Cram an Clara Cram' },
|
||||||
|
{ kind:'letter', title:'H-0367 – 20. Dezember 1915 – Feldpost', meta:'20.12.1915 · von Herbert Cram an Clara Cram' },
|
||||||
|
{ kind:'narration', text:'1916. Die Schlachten bei Verdun und an der Somme fordern über eine Million Opfer. Herbert ist in Belgien stationiert. Er schreibt über Weihnachtsgrüße und Claras Ausflug nach Baden-Baden. Er schreibt alles – nur nicht über das, was er täglich vor Augen hat.' },
|
||||||
|
{ kind:'letter', title:'H-0370 – 29. März 1916 – Feldpost', meta:'29.03.1916 · von Herbert Cram an Clara Cram' },
|
||||||
|
{ kind:'letter', title:'H-0372 – 5. Mai 1916 – Feldpost', meta:'05.05.1916 · von Herbert Cram an Clara Cram' },
|
||||||
|
{ kind:'letter', title:'H-0023 – 6. Juli 1916 – Feldpost', meta:'06.07.1916 · von Herbert Cram an Marie Cram' },
|
||||||
|
{ kind:'note', text:'Herbert beschreibt einen gewaltigen Stadtbrand – Kathedrale, Warenhäuser. Vermutlich ein Ort in Belgien oder Nordfrankreich. Er schildert es nüchtern, fast wie ein Beobachter. Erst am Ende kommt ein Gruß an Clara.' },
|
||||||
|
{ kind:'letter', title:'H-0014 – 21. Juli 1916 – Feldpost', meta:'21.07.1916 · von Herbert Cram an Marie Cram' },
|
||||||
|
{ kind:'letter', title:'H-0015 – 21. Juli 1916 – Feldpost', meta:'21.07.1916 · von Herbert Cram an Marie Cram' },
|
||||||
|
{ kind:'note', text:'Dieser Brief ist einer der eindringlichsten des Archivs. Herbert schrieb ihn am Morgen eines Gefechtstages – zehn Minuten, bevor er in Stellung ging. Er kehrte zurück.' },
|
||||||
|
{ kind:'letter', title:'H-0016 – 22. Juli 1916 – Feldpost', meta:'22.07.1916 · von Herbert Cram an Marie Cram' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const s = this.story();
|
||||||
|
const author = this.av('Marcel Raddatz');
|
||||||
|
const blocks = s.blocks.map(b => ({
|
||||||
|
...b,
|
||||||
|
isNarration: b.kind==='narration',
|
||||||
|
isLetter: b.kind==='letter',
|
||||||
|
isNote: b.kind==='note',
|
||||||
|
iconEl: b.kind==='letter' ? React.createElement('img', { className:'dgicon', src:'assets/icons/Mail-MD.svg', style:{ width:18, height:18, opacity:.5 } }) : null,
|
||||||
|
}));
|
||||||
|
const introStyle = { fontFamily:'var(--font-serif)', fontSize:18, lineHeight:1.75, margin:'0', fontStyle: s.introItalic ? 'italic' : 'normal', color: s.introItalic ? 'var(--c-ink-2)' : 'var(--c-ink)' };
|
||||||
|
return {
|
||||||
|
badge:s.badge, title:s.title, intro:s.intro, introStyle,
|
||||||
|
bylineMeta: s.dateVerb + ' ' + s.date,
|
||||||
|
sectionHeading: s.sectionHeading,
|
||||||
|
author, blocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="geschichten" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Sammlungen</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Geschichten</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Kuratierte Erzählungen aus Briefen, Karten und Fotografien des Archivs.</p>
|
||||||
|
</div>
|
||||||
|
<button class="fa-link" style="display:inline-flex; align-items:center; gap:8px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 16px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Neue Geschichte</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- filter -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; flex-wrap:wrap; gap:12px">
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line)">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 16px; cursor:pointer">Alle</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Veröffentlicht</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">In Arbeit</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Entwurf</span>
|
||||||
|
</div>
|
||||||
|
<button class="fa-link" style="display:flex; align-items:center; gap:8px; background:var(--c-surface); border:1px solid var(--c-line); padding:9px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer">
|
||||||
|
<img class="dgicon" src="assets/icons/Filter-MD.svg" style="width:15px; height:15px; opacity:.55"> Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:18px">
|
||||||
|
<sc-for list="{{ stories }}" as="s" hint-placeholder-count="6">
|
||||||
|
<a href="Geschichte.dc.html" class="fa-link" style="display:flex; flex-direction:column; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); padding:22px 22px 20px; text-decoration:none">
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:14px">
|
||||||
|
<sc-for list="{{ s.tags }}" as="t" hint-placeholder-count="2">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px; background:var(--c-muted); border:1px solid var(--c-line-2); padding:3px 9px; border-radius:4px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.13em; text-transform:uppercase; color:var(--c-ink)"><span style="{{ t.dot }}"></span>{{ t.l }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<h3 style="font-family:var(--font-serif); font-size:24px; font-weight:700; color:var(--c-ink); margin:0 0 8px; line-height:1.2">{{ s.title }}</h3>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:15px; line-height:1.55; color:var(--c-ink-2); margin:0 0 18px">{{ s.dek }}</p>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid var(--c-line-2)">
|
||||||
|
<img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
<span>{{ s.range }}</span><span>·</span><span>{{ s.docs }} Dokumente</span><span>·</span><span>{{ s.persons }} Personen</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between">
|
||||||
|
<div style="display:flex; padding-left:6px">
|
||||||
|
<sc-for list="{{ s.people }}" as="p" hint-placeholder-count="3">
|
||||||
|
<span style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px; font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)"><span style="{{ s.statusDot }}"></span>{{ s.status }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name, a26:{ ...base, width:26, height:26, fontSize:10, marginLeft:-6, border:'2px solid var(--c-surface)' } };
|
||||||
|
}
|
||||||
|
avs(names){ return names.map(n => this.av(n)); }
|
||||||
|
|
||||||
|
stories(){
|
||||||
|
const raw = [
|
||||||
|
{ title:'Die Briefe aus dem Harz', dek:'Eine Sommerkorrespondenz zwischen Frieda und ihrer Schwester, voller Wetterberichte und stiller Sehnsucht.', range:'1923–1925', docs:14, persons:4, status:'Veröffentlicht', tags:[{l:'Familie',d:'#5a8a6a'},{l:'Reise',d:'#c17a00'}], people:['Frieda Rose','Anna Bauer','Wilhelm Rose','Heinrich Rose'] },
|
||||||
|
{ title:'Das Grundstück in Mariahilf', dek:'Ein Erbschaftsstreit, erzählt in nüchternen Geschäftsbriefen über drei Jahre.', range:'1925–1928', docs:9, persons:6, status:'In Arbeit', tags:[{l:'Recht',d:'#607080'},{l:'Wien',d:'#3060b0'}], people:['Otto Schmidt','Karl Müller','Margarete Hoffmann'] },
|
||||||
|
{ title:'Wilhelms Lehrjahre', dek:'Briefe aus der Ausbildung — vom Heimweh bis zum ersten Lohn.', range:'1928–1931', docs:11, persons:3, status:'Entwurf', tags:[{l:'Arbeit',d:'#9a8040'}], people:['Wilhelm Rose','Frieda Rose'] },
|
||||||
|
{ title:'Weihnachten bei den Roses', dek:'Glückwünsche und Grüße über drei Jahrzehnte hinweg gesammelt.', range:'1919–1948', docs:23, persons:9, status:'Veröffentlicht', tags:[{l:'Familie',d:'#5a8a6a'},{l:'Fest',d:'#c0446e'}], people:['Margarete Hoffmann','Heinrich Rose','Elise Vogt','Anna Bauer'] },
|
||||||
|
{ title:'Postkarten einer Reise', dek:'Wien, Triest, Venedig — Ansichten und kurze Zeilen von unterwegs.', range:'1924', docs:7, persons:2, status:'In Arbeit', tags:[{l:'Reise',d:'#c17a00'}], people:['Karl Müller','Frieda Rose'] },
|
||||||
|
{ title:'Stimmen aus dem Feld', dek:'Feldpost und Heimatbriefe der Jahre 1914 bis 1918.', range:'1914–1918', docs:18, persons:5, status:'Entwurf', tags:[{l:'Krieg',d:'#a0522d'}], people:['Heinrich Rose','Otto Schmidt','Wilhelm Rose'] },
|
||||||
|
];
|
||||||
|
return raw.map(s => {
|
||||||
|
const sc = ({'Veröffentlicht':'#5a8a6a','In Arbeit':'#c17a00','Entwurf':'#607080'})[s.status] || '#607080';
|
||||||
|
return { ...s, people:this.avs(s.people),
|
||||||
|
statusDot:{ display:'inline-block', width:7, height:7, borderRadius:'999px', background:sc, flexShrink:0 },
|
||||||
|
tags:s.tags.map(t => ({ l:t.l, dot:{ display:'inline-block', width:7, height:7, borderRadius:'999px', background:t.d, flexShrink:0 } })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
return { stories: this.stories() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<!-- active="hilfe" is intentionally NOT a nav key → nothing in ArchiveHeader highlights -->
|
||||||
|
<dc-import name="ArchiveHeader" active="hilfe" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:880px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- §3 PageHeader -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Hilfe</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Transkription</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:560px">Damit alle Briefe einheitlich übertragen werden — gleich, wer am Schreibtisch sitzt — finden Sie hier die Konventionen für das Familienarchiv.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- intro paragraph (Tinos) -->
|
||||||
|
<p style="font-family:var(--font-serif); font-size:18px; line-height:1.75; color:var(--c-ink); margin:0 0 28px; max-width:760px">Die Briefe dieses Archivs sind in <em>Kurrent</em> und <em>Sütterlin</em> geschrieben — der deutschen Schreibschrift des späten 19. und frühen 20. Jahrhunderts. Übertragen Sie den Text so getreu wie möglich, ohne ihn zu modernisieren. Wo Sie unsicher sind, machen Sie die Unsicherheit sichtbar, statt sie zu verstecken. Diese Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.</p>
|
||||||
|
|
||||||
|
<!-- Wikipedia / Weiterführend card (3px mint top) -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px; margin-bottom:36px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 12px">Weiterführend</h2>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:16px; line-height:1.65; color:var(--c-ink-2); margin:0 0 16px">Die Kurrent- und Sütterlin-Alphabete sind bei Wikipedia ausführlich erklärt — mit Buchstabentafeln zum Vergleichen. Hier auf dieser Seite stehen nur unsere eigenen Vereinbarungen für dieses Archiv.</p>
|
||||||
|
<!-- EXTERNAL link: in production this carries rel="noopener noreferrer" + target="_blank" -->
|
||||||
|
<a href="https://de.wikipedia.org/wiki/Kurrentschrift" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:13px; font-weight:700; letter-spacing:.04em; color:var(--c-ink); text-decoration:underline; text-decoration-color:var(--c-accent); text-decoration-thickness:2px; text-underline-offset:3px">
|
||||||
|
<span>Kurrentschrift auf Wikipedia</span>
|
||||||
|
<img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rules section caption -->
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 18px">Regeln für die Transkription</h2>
|
||||||
|
|
||||||
|
<!-- Rule cards (RichtlinienRuleCard pattern) -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; margin-bottom:40px">
|
||||||
|
<sc-for list="{{ rules }}" as="r" hint-placeholder-count="6">
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:22px 24px">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px">
|
||||||
|
<span style="width:36px; height:36px; flex-shrink:0; background:var(--c-muted); border:1px solid var(--c-line-2); border-radius:2px; display:flex; align-items:center; justify-content:center">{{ r.iconEl }}</span>
|
||||||
|
<h3 style="font-family:var(--font-serif); font-size:20px; font-weight:700; color:var(--c-ink); margin:0; line-height:1.2">{{ r.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:16px; line-height:1.65; color:var(--c-ink-2); margin:0">{{ r.body }}</p>
|
||||||
|
|
||||||
|
<!-- example block on subtle --c-muted background: Kurrent snippet vs transcription -->
|
||||||
|
<sc-if value="{{ r.hasExample }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="background:var(--c-muted); border:1px solid var(--c-line-2); border-radius:2px; padding:14px 16px; margin-top:16px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Beispiel</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap">
|
||||||
|
<span style="font-family:var(--font-serif); font-style:italic; font-size:17px; color:var(--c-ink-2); {{ r.inStyle }}">{{ r.in }}</span>
|
||||||
|
<img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:14px; height:14px; opacity:.4; flex-shrink:0">
|
||||||
|
<span style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink)">{{ r.out }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- In Klärung section -->
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 10px">In Klärung</h2>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:16px; line-height:1.65; color:var(--c-ink-2); margin:0 0 16px; max-width:760px">Diese Fragen besprechen wir noch. Stoßen Sie beim Transkribieren darauf, treffen Sie eine plausible Wahl und notieren Sie sie im Kommentar des Blocks.</p>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:40px">
|
||||||
|
<sc-for list="{{ klaerungChips }}" as="c" hint-placeholder-count="5">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:999px; padding:7px 14px">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:#c17a00; flex-shrink:0"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2)">{{ c }}</span>
|
||||||
|
</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- closing card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px">
|
||||||
|
<span style="width:36px; height:36px; flex-shrink:0; background:var(--c-muted); border:1px solid var(--c-line-2); border-radius:2px; display:flex; align-items:center; justify-content:center">
|
||||||
|
<img class="dgicon" src="assets/icons/Chat-MD.svg" style="width:18px; height:18px; opacity:.5">
|
||||||
|
</span>
|
||||||
|
<h3 style="font-family:var(--font-serif); font-size:20px; font-weight:700; color:var(--c-ink); margin:0; line-height:1.2">Fehlt eine Regel?</h3>
|
||||||
|
</div>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:16px; line-height:1.65; color:var(--c-ink-2); margin:0">Stolpern Sie über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln die offenen Fälle und besprechen sie beim nächsten Familientreffen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
icon(src){
|
||||||
|
return React.createElement('img', { className:'dgicon', src:'assets/icons/' + src, style:{ width:18, height:18, opacity:.5 } });
|
||||||
|
}
|
||||||
|
renderVals(){
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
icon:'Mag-Glass-MD.svg',
|
||||||
|
title:'Nicht lesbare Wörter',
|
||||||
|
body:'Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Lassen Sie nichts einfach weg — die Lücke fällt anderen sonst nicht auf.',
|
||||||
|
hasExample:true,
|
||||||
|
in:'…und ſende Dir ▢▢▢ Grüße',
|
||||||
|
out:'und sende Dir [unleserlich] Grüße',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon:'Edit-Content-MD.svg',
|
||||||
|
title:'Durchgestrichene Wörter',
|
||||||
|
body:'Auch durchgestrichener Text gehört zum Brief. Übernehmen Sie ihn in eckigen Klammern mit dem Präfix „durchgestrichen".',
|
||||||
|
hasExample:true,
|
||||||
|
in:'der Text',
|
||||||
|
inStrike:true,
|
||||||
|
out:'[durchgestrichen: der Text]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon:'Check-MD.svg',
|
||||||
|
title:'Das lange s (ſ)',
|
||||||
|
body:'Das ſ ist nur eine alte Schriftform des Buchstabens s — kein eigener Laut. Schreiben Sie immer ein normales s.',
|
||||||
|
hasExample:true,
|
||||||
|
in:'Straße, Poſthaus, Schloſs',
|
||||||
|
out:'Straße, Posthaus, Schloss',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon:'View-More-MD.svg',
|
||||||
|
title:'Unsichere Lesart',
|
||||||
|
body:'Wenn Sie ein Wort oder einen Namen zu erkennen meinen, aber nicht sicher sind, ergänzen Sie ein Fragezeichen in eckigen Klammern direkt dahinter.',
|
||||||
|
hasExample:true,
|
||||||
|
in:'Grüße an Tante Mar…',
|
||||||
|
out:'Grüße an Tante [Marie?]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon:'Edit-Content-MD.svg',
|
||||||
|
title:'Originalschreibung bewahren',
|
||||||
|
body:'Übernehmen Sie Rechtschreibung, Zeichensetzung und alte Wortformen genau so, wie sie dastehen. Modernisieren Sie nicht. Anführungen setzen Sie als „…", längere wörtliche Reden bleiben unverändert.',
|
||||||
|
hasExample:true,
|
||||||
|
in:'Theil, Thür, giebt es',
|
||||||
|
out:'Theil, Thür, giebt es',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon:'Chat-MD.svg',
|
||||||
|
title:'Dialekt, Fremdwörter, Zitate',
|
||||||
|
body:'Plattdeutsch, Französisch, lateinische Phrasen — wörtlich übernehmen, genau wie sie geschrieben stehen. Übersetzen Sie nicht und glätten Sie nichts.',
|
||||||
|
hasExample:false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mapped = rules.map(r => ({
|
||||||
|
...r,
|
||||||
|
iconEl: this.icon(r.icon),
|
||||||
|
inStyle: r.inStrike ? 'text-decoration:line-through' : '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: mapped,
|
||||||
|
klaerungChips: ['Abkürzungen', 'Datumsformate', 'Originale Zeilenumbrüche', 'Alte Groß- und Kleinschreibung'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
@media (max-width: 760px){ .pd-grid{ grid-template-columns:1fr !important } }
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="personen" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- PageHeader (§3) -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Person</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">{{ person.name }}</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">{{ person.lede }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Bearbeiten</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2-column layout: 340px left, flexible right; wraps on narrow -->
|
||||||
|
<div class="pd-grid" style="display:grid; grid-template-columns:340px 1fr; gap:24px; align-items:start">
|
||||||
|
|
||||||
|
<!-- ════════ LEFT COLUMN ════════ -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:24px">
|
||||||
|
|
||||||
|
<!-- Identity card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<div style="display:flex; align-items:flex-start; gap:14px">
|
||||||
|
<span style="{{ person.a48 }}">{{ person.initials }}</span>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<h2 style="font-family:var(--font-serif); font-size:24px; font-weight:700; color:var(--c-ink); margin:0; line-height:1.2">{{ person.name }}</h2>
|
||||||
|
<sc-if value="{{ person.badge }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="{{ person.badge.style }}">{{ person.badge.label }}</span>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ person.alias }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:14px 0 0">„{{ person.alias }}"</p>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<p style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3); margin:12px 0 0">
|
||||||
|
<span aria-hidden="true">*</span> {{ person.birthYear }} <span style="margin:0 4px">–</span> <span aria-hidden="true">†</span> {{ person.deathYear }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="height:1px; background:var(--c-line-2); margin:18px 0"></div>
|
||||||
|
|
||||||
|
<!-- MetaLine (§8) -->
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
<span>{{ person.letters }} Briefe</span><span>·</span><span>{{ person.docs }} Dokumente</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Namensverlauf card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Namensverlauf</h2>
|
||||||
|
<ol style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:12px">
|
||||||
|
<sc-for list="{{ nameHistory }}" as="n" hint-placeholder-count="3">
|
||||||
|
<li style="display:flex; align-items:baseline; gap:10px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); white-space:nowrap; min-width:74px">{{ n.kind }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:15px; color:var(--c-ink)">{{ n.name }}</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-left:auto; white-space:nowrap">{{ n.year }}</span>
|
||||||
|
</li>
|
||||||
|
</sc-for>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════ RIGHT COLUMN ════════ -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:24px; min-width:0">
|
||||||
|
|
||||||
|
<!-- Korrespondenten card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Korrespondenten</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px">
|
||||||
|
<sc-for list="{{ correspondents }}" as="c" hint-placeholder-count="5">
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:12px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; padding:8px 12px; text-decoration:none">
|
||||||
|
<span style="{{ c.a40 }}">{{ c.initials }}</span>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:15px; color:var(--c-ink); line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis">{{ c.name }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-top:2px">{{ c.meta }}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beziehungen card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Beziehungen</h2>
|
||||||
|
<ul style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:10px">
|
||||||
|
<sc-for list="{{ relationships }}" as="r" hint-placeholder-count="4">
|
||||||
|
<li style="display:flex; align-items:center; gap:12px">
|
||||||
|
<span style="flex-shrink:0; min-width:96px; background:var(--c-muted); color:var(--c-ink-2); border:1px solid var(--c-line); border-radius:4px; padding:4px 10px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; text-align:center">{{ r.role }}</span>
|
||||||
|
<a href="#" class="fa-link" style="flex:1; min-width:0; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); text-decoration:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis">{{ r.name }}</a>
|
||||||
|
<span style="flex-shrink:0; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); white-space:nowrap">{{ r.dates }}</span>
|
||||||
|
</li>
|
||||||
|
</sc-for>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Briefe — Gesendet -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0">Briefe — Gesendet</h2>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ sentCount }} gesamt</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px">
|
||||||
|
<sc-for list="{{ sentLetters }}" as="b" hint-placeholder-count="4">
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:16px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; padding:12px 16px; text-decoration:none">
|
||||||
|
<span style="width:40px; height:40px; flex-shrink:0; background:var(--c-muted); border:1px solid var(--c-line-2); display:flex; align-items:center; justify-content:center">{{ b.iconEl }}</span>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink); line-height:1.3">{{ b.title }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-top:3px">{{ b.meta }}</div>
|
||||||
|
</div>
|
||||||
|
<img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:18px; height:18px; opacity:.4; flex-shrink:0">
|
||||||
|
</a>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Briefe — Empfangen -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0">Briefe — Empfangen</h2>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ receivedCount }} gesamt</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px">
|
||||||
|
<sc-for list="{{ receivedLetters }}" as="b" hint-placeholder-count="4">
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:16px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; padding:12px 16px; text-decoration:none">
|
||||||
|
<span style="width:40px; height:40px; flex-shrink:0; background:var(--c-muted); border:1px solid var(--c-line-2); display:flex; align-items:center; justify-content:center">{{ b.iconEl }}</span>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink); line-height:1.3">{{ b.title }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-top:3px">{{ b.meta }}</div>
|
||||||
|
</div>
|
||||||
|
<img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:18px; height:18px; opacity:.4; flex-shrink:0">
|
||||||
|
</a>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Geschichten card -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Geschichten</h2>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px">
|
||||||
|
<sc-for list="{{ stories }}" as="g" hint-placeholder-count="2">
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:16px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; padding:12px 16px; text-decoration:none">
|
||||||
|
<span style="width:40px; height:40px; flex-shrink:0; background:var(--c-muted); border:1px solid var(--c-line-2); display:flex; align-items:center; justify-content:center">{{ g.iconEl }}</span>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink); line-height:1.3">{{ g.title }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-top:3px">{{ g.meta }}</div>
|
||||||
|
</div>
|
||||||
|
<img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:18px; height:18px; opacity:.4; flex-shrink:0">
|
||||||
|
</a>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
mailIcon(){
|
||||||
|
return React.createElement('img', { className:'dgicon', src:'assets/icons/Mail-MD.svg', style:{ width:18, height:18, opacity:.5 } });
|
||||||
|
}
|
||||||
|
bookIcon(){
|
||||||
|
return React.createElement('img', { className:'dgicon', src:'assets/icons/Library-MD.svg', style:{ width:18, height:18, opacity:.5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const person = {
|
||||||
|
...this.av('Clara Cram'),
|
||||||
|
lede:'Ehefrau von Herbert · 1885–1962',
|
||||||
|
alias:'Clärchen',
|
||||||
|
birthYear:'1885',
|
||||||
|
deathYear:'1962',
|
||||||
|
letters:118,
|
||||||
|
docs:142,
|
||||||
|
badge:null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameHistory = [
|
||||||
|
{ kind:'Geburtsname', name:'Clara de Gruyter', year:'1885' },
|
||||||
|
{ kind:'Verheiratet', name:'Clara Cram', year:'1908' },
|
||||||
|
{ kind:'Verwitwet', name:'Clara Cram, geb. de Gruyter', year:'1949' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const correspondents = [
|
||||||
|
{ name:'Herbert Cram', meta:'74 Briefe' },
|
||||||
|
{ name:'Marie Cram', meta:'19 Briefe' },
|
||||||
|
{ name:'Eugenie de Gruyter', meta:'12 Briefe' },
|
||||||
|
{ name:'Hannemarie Cram', meta:'8 Briefe' },
|
||||||
|
{ name:'Walter de Gruyter', meta:'5 Briefe' },
|
||||||
|
].map(c => ({ ...this.av(c.name), meta:c.meta }));
|
||||||
|
|
||||||
|
const relationships = [
|
||||||
|
{ role:'Ehemann', name:'Herbert Cram', dates:'1908–1949' },
|
||||||
|
{ role:'Tochter', name:'Hannemarie Cram', dates:'* 1910' },
|
||||||
|
{ role:'Tochter', name:'Clara-Eugenie Cram', dates:'* 1913' },
|
||||||
|
{ role:'Sohn', name:'Kurt-Georg Cram', dates:'* 1916' },
|
||||||
|
{ role:'Mutter', name:'Eugenie de Gruyter', dates:'1860–1934' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sentCount = 74;
|
||||||
|
const sentLetters = [
|
||||||
|
{ title:'C-0211 – 4. März 1916 – Wiesbaden', meta:'04.03.1916 · von Clara Cram an Herbert Cram' },
|
||||||
|
{ title:'C-0214 – 19. Mai 1916 – Wiesbaden', meta:'19.05.1916 · von Clara Cram an Herbert Cram' },
|
||||||
|
{ title:'C-0240 – 2. Oktober 1917 – Bad Ems', meta:'02.10.1917 · von Clara Cram an Herbert Cram' },
|
||||||
|
{ title:'C-0098 – 11. Juli 1918 – Wiesbaden', meta:'11.07.1918 · von Clara Cram an Marie Cram' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const receivedCount = 44;
|
||||||
|
const receivedLetters = [
|
||||||
|
{ title:'H-0366 – 31. Oktober 1915 – Feldpost', meta:'31.10.1915 · von Herbert Cram an Clara Cram' },
|
||||||
|
{ title:'H-0370 – 29. März 1916 – Feldpost', meta:'29.03.1916 · von Herbert Cram an Clara Cram' },
|
||||||
|
{ title:'Eu-0341 – 8. April 1917 – Ruhrort', meta:'08.04.1917 · von Eugenie de Gruyter an Clara Cram' },
|
||||||
|
{ title:'H-0405 – 2. November 1918 – Feldpost', meta:'02.11.1918 · von Herbert Cram an Clara Cram' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stories = [
|
||||||
|
{ title:'Feldpost: Herbert an der Westfront 1914–1918', meta:'Lesereise · 18 Briefe' },
|
||||||
|
{ title:'Die Kinder: Briefe an und über Hannemarie, Clara-Eugenie und Kurt-Georg', meta:'Sammlung · 31 Dokumente' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const withMail = (arr) => arr.map(b => ({ ...b, iconEl:this.mailIcon() }));
|
||||||
|
const withBook = (arr) => arr.map(g => ({ ...g, iconEl:this.bookIcon() }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
person, nameHistory, correspondents, relationships,
|
||||||
|
sentCount, sentLetters:withMail(sentLetters),
|
||||||
|
receivedCount, receivedLetters:withMail(receivedLetters),
|
||||||
|
stories:withBook(stories),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="personen" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:780px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- back link -->
|
||||||
|
<a href="Personen.dc.html" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none; margin-bottom:24px">← Zurück zu Personen</a>
|
||||||
|
|
||||||
|
<!-- PageHeader (§3) -->
|
||||||
|
<div style="margin-bottom:30px; border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">{{ eyebrow }}</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">{{ title }}</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">{{ lede }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Stammdaten ─── -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Stammdaten</h2>
|
||||||
|
|
||||||
|
<!-- Personentyp — segmented control (§5/§6) -->
|
||||||
|
<div style="margin-bottom:22px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Personentyp</div>
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line); border-radius:2px; overflow:hidden">
|
||||||
|
<sc-for list="{{ typeSegments }}" as="seg" hint-placeholder-count="4">
|
||||||
|
<span class="fa-link" style="{{ seg.style }}">{{ seg.label }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anrede/Titel + Vorname -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:18px; margin-bottom:18px">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Anrede / Titel</span>
|
||||||
|
<input value="{{ f.title }}" placeholder="z.B. Frau, Dr." style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Vorname *</span>
|
||||||
|
<input value="{{ f.firstName }}" placeholder="z.B. Clara" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nachname -->
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Nachname *</span>
|
||||||
|
<input value="{{ f.lastName }}" placeholder="z.B. Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Aliasnamen -->
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Aliasnamen</span>
|
||||||
|
<input value="{{ f.alias }}" placeholder="z.B. Clärchen, geb. de Gruyter" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Lebensdaten: Geburtsdatum + Sterbedatum with precision hint -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:18px; margin-bottom:18px">
|
||||||
|
<div>
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Geburtsdatum</span>
|
||||||
|
<input value="{{ f.birthDate }}" placeholder="TT.MM.JJJJ" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-top:7px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">
|
||||||
|
<span>Genauigkeit:</span><span style="color:var(--c-ink-2)">Tag</span><span>·</span><span>Monat</span><span>·</span><span>Jahr</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Sterbedatum</span>
|
||||||
|
<input value="{{ f.deathDate }}" placeholder="TT.MM.JJJJ" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-top:7px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">
|
||||||
|
<span>Genauigkeit:</span><span>Tag</span><span>·</span><span>Monat</span><span>·</span><span style="color:var(--c-ink-2)">Jahr</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generation (select) -->
|
||||||
|
<label style="display:block; margin-bottom:18px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Generation</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<select style="width:100%; appearance:none; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px; cursor:pointer; min-height:44px">
|
||||||
|
<option>Keine Generation</option>
|
||||||
|
<option>G 0</option><option>G 1</option><option selected>G 2</option>
|
||||||
|
<option>G 3</option><option>G 4</option><option>G 5</option><option>G 6</option>
|
||||||
|
</select>
|
||||||
|
<span style="position:absolute; right:14px; top:50%; transform:translateY(-50%); font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); pointer-events:none">▾</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:7px 0 0">Ordnet die Person einer Generation im Stammbaum zu.</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Notizen (textarea) -->
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Notizen</span>
|
||||||
|
<textarea rows="4" placeholder="Anmerkungen zu dieser Person…" style="width:100%; resize:vertical; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; line-height:1.6; color:var(--c-ink); outline:none; border-radius:2px">{{ f.notes }}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Namensverlauf ─── -->
|
||||||
|
<div style="margin-top:24px; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Namensverlauf</h2>
|
||||||
|
|
||||||
|
<sc-if value="{{ hasAliases }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="margin-bottom:18px">
|
||||||
|
<sc-for list="{{ aliasRows }}" as="a" hint-placeholder-count="3">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:14px; padding:12px 0; border-bottom:1px solid var(--c-line-2)">
|
||||||
|
<div style="display:flex; align-items:baseline; gap:10px; min-width:0">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); white-space:nowrap">{{ a.kind }}</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">{{ a.name }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" title="Diesen Namen entfernen" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-width:44px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Entfernen</a>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ noAliases }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="border:1px dashed var(--c-line); border-radius:2px; padding:28px 24px; text-align:center; margin-bottom:18px">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:18px; color:var(--c-ink); margin-bottom:6px">Noch keine früheren Namen erfasst.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Ergänzen Sie Geburts- oder Ehenamen unten…</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- add row -->
|
||||||
|
<div style="border-top:1px solid var(--c-line); padding-top:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:12px">Namen hinzufügen</div>
|
||||||
|
<div style="display:grid; grid-template-columns:160px 1fr 1fr auto; gap:12px; align-items:end">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Art</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<select style="width:100%; appearance:none; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 36px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px; cursor:pointer; min-height:44px">
|
||||||
|
<option>Geburtsname</option><option>Ehename (verwitwet)</option><option>Ehename (geschieden)</option><option>Sonstiger</option>
|
||||||
|
</select>
|
||||||
|
<span style="position:absolute; right:12px; top:50%; transform:translateY(-50%); font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); pointer-events:none">▾</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Nachname</span>
|
||||||
|
<input placeholder="z.B. de Gruyter" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Vorname</span>
|
||||||
|
<input placeholder="z.B. Clara" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Zusammenführen (danger zone) — edit only ─── -->
|
||||||
|
<sc-if value="{{ isEdit }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="margin-top:24px; background:var(--c-surface); border:1px solid var(--c-danger); border-top:3px solid var(--c-danger); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-danger); margin:0 0 6px">Zusammenführen</h2>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:15px; line-height:1.6; color:var(--c-ink-2); margin:0 0 18px; max-width:520px">Diese Person in eine andere überführen. Alle Briefe und Verknüpfungen wandern zur Zielperson — <span style="font-style:italic">{{ title }}</span> wird danach gelöscht.</p>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap">
|
||||||
|
<label style="display:block; flex:1; min-width:240px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); margin-bottom:6px">Zielperson</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input placeholder="Person suchen…" style="width:100%; border:1px solid var(--c-danger); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button disabled class="fa-link" title="Wählen Sie zuerst eine Zielperson" style="background:var(--c-surface); color:var(--c-danger); border:1px solid var(--c-danger); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; border-radius:2px; opacity:.4; cursor:not-allowed">Zusammenführen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ─── Save bar ─── -->
|
||||||
|
<div style="margin-top:24px; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:16px 24px">
|
||||||
|
<a href="{{ discardHref }}" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">Verwerfen</a>
|
||||||
|
<div style="display:flex; align-items:center; gap:18px">
|
||||||
|
<sc-if value="{{ isEdit }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Löschen</a>
|
||||||
|
</sc-if>
|
||||||
|
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">{{ saveLabel }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1500},"variant":{"editor":"enum","options":["neu","bearbeiten"],"default":"bearbeiten","tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
segments(activeLabel){
|
||||||
|
const labels = ['Person','Institution','Gruppe','Unbekannt'];
|
||||||
|
return labels.map((label, i) => {
|
||||||
|
const active = label === activeLabel;
|
||||||
|
const style = {
|
||||||
|
fontFamily:'var(--font-sans)', fontSize:12, fontWeight:700, letterSpacing:'.08em',
|
||||||
|
textTransform:'uppercase', padding:'10px 16px', cursor:'pointer',
|
||||||
|
background: active ? 'var(--c-primary)' : 'var(--c-surface)',
|
||||||
|
color: active ? 'var(--c-primary-fg)' : 'var(--c-ink-2)',
|
||||||
|
};
|
||||||
|
if (i > 0) style.borderLeft = '1px solid var(--c-line)';
|
||||||
|
return { label, style };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
const variant = this.props.variant || 'bearbeiten';
|
||||||
|
const isEdit = variant === 'bearbeiten';
|
||||||
|
|
||||||
|
const editing = {
|
||||||
|
title:'Frau', firstName:'Clara', lastName:'Cram', alias:'Clärchen, geb. de Gruyter',
|
||||||
|
birthDate:'24.09.1871', deathDate:'1953',
|
||||||
|
notes:'Ehefrau von Herbert Cram. Über 440 Feldpostbriefe an sie gerichtet (1914–1918). Tochter von Walter und Eugenie de Gruyter.',
|
||||||
|
};
|
||||||
|
const blank = {
|
||||||
|
title:'', firstName:'', lastName:'', alias:'',
|
||||||
|
birthDate:'', deathDate:'', notes:'',
|
||||||
|
};
|
||||||
|
const f = isEdit ? editing : blank;
|
||||||
|
|
||||||
|
const aliasRows = [
|
||||||
|
{ kind:'Geburtsname', name:'Clara de Gruyter' },
|
||||||
|
{ kind:'Ehename', name:'Clara Cram' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fullName = ((f.firstName + ' ' + f.lastName).trim()) || 'Neue Person';
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEdit,
|
||||||
|
eyebrow: isEdit ? 'Person bearbeiten' : 'Neue Person',
|
||||||
|
title: isEdit ? fullName : 'Neue Person',
|
||||||
|
lede: isEdit
|
||||||
|
? 'Pflegen Sie Stammdaten, Lebensdaten und Namensverlauf dieser Person.'
|
||||||
|
: 'Legen Sie eine neue Person, Institution oder Gruppe für das Archiv an.',
|
||||||
|
f,
|
||||||
|
typeSegments: this.segments('Person'),
|
||||||
|
hasAliases: isEdit,
|
||||||
|
noAliases: !isEdit,
|
||||||
|
aliasRows,
|
||||||
|
discardHref: isEdit ? 'Person.dc.html' : 'Personen.dc.html',
|
||||||
|
saveLabel: isEdit ? 'Änderungen speichern' : 'Person anlegen',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="personen" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- PAGE HEADER (§3) -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Prüfen</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Personen prüfen</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Vom Import erzeugte, noch nicht bestätigte Personen. Bitte prüfen Sie jeden Eintrag — bestätigen, umbenennen, zusammenführen oder löschen.</p>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">12 zu prüfen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- REVIEW ROWS -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:14px">
|
||||||
|
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4">
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:18px 20px">
|
||||||
|
|
||||||
|
<!-- ROW HEADER (always shown) -->
|
||||||
|
<div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap">
|
||||||
|
<!-- 40px MUTED avatar — deliberate "unbestätigt" signal, never a colored avatar -->
|
||||||
|
<span style="width:40px; height:40px; flex-shrink:0; border-radius:999px; background:var(--c-muted); display:inline-flex; align-items:center; justify-content:center" title="Unbestätigt">
|
||||||
|
<img class="dgicon" src="assets/icons/Account-MD.svg" style="width:20px; height:20px; opacity:.5">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:19px; font-weight:700; color:var(--c-ink); line-height:1.2; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ r.name }}</div>
|
||||||
|
<!-- MetaLine (§8) -->
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-top:4px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
<span>{{ r.docs }} Dokumente</span><span>·</span><span>zuerst gesehen {{ r.firstSeen }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ACTION CLUSTER — segmented-style button group + danger -->
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap">
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line); border-radius:2px; overflow:hidden">
|
||||||
|
<span class="fa-link" style="min-height:44px; display:inline-flex; align-items:center; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 15px; cursor:pointer">Bestätigen</span>
|
||||||
|
<span class="fa-link" style="min-height:44px; display:inline-flex; align-items:center; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 15px; border-left:1px solid var(--c-line); cursor:pointer">Umbenennen</span>
|
||||||
|
<span class="fa-link" style="min-height:44px; display:inline-flex; align-items:center; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 15px; border-left:1px solid var(--c-line); cursor:pointer">Zusammenführen</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="fa-link" style="min-height:44px; display:inline-flex; align-items:center; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none; padding:0 6px">Löschen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UMBENENNEN sub-panel (§7 fields) -->
|
||||||
|
<sc-if value="{{ r.rename }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="margin-top:16px; background:var(--c-muted); border-radius:2px; padding:18px 18px 16px">
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap">
|
||||||
|
<label style="display:block; flex:1; min-width:160px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Vorname</span>
|
||||||
|
<input value="Margarethe" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block; flex:1; min-width:160px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Nachname</span>
|
||||||
|
<input value="Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<div style="display:flex; gap:10px">
|
||||||
|
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ZUSAMMENFÜHREN sub-panel — typeahead + disabled-until-selected danger merge -->
|
||||||
|
<sc-if value="{{ r.merge }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="margin-top:16px; background:var(--c-muted); border-radius:2px; padding:18px 18px 16px">
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap">
|
||||||
|
<label style="display:block; flex:1; min-width:220px">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Mit welcher Person zusammenführen?</span>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input placeholder="z.B. Clara Cram, Herbert Cram…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button title="Bitte zuerst eine Zielperson wählen" style="background:var(--c-danger); color:#fff; border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:not-allowed; border-radius:2px; opacity:.4">Zusammenführen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EMPTY STATE (§10) — shown as a quiet reference of the "nothing left" terminal state -->
|
||||||
|
<div style="margin-top:14px; border:1px dashed var(--c-line); border-radius:2px; padding:48px 24px; text-align:center">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:20px; color:var(--c-ink); margin-bottom:8px">Keine Personen zu prüfen.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Alle Einträge wurden bestätigt — neue erscheinen nach dem nächsten Import…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAGINATION FOOTER -->
|
||||||
|
<div style="margin-top:28px; display:flex; align-items:center; justify-content:center; gap:8px">
|
||||||
|
<a href="#" class="fa-link" style="min-height:44px; display:inline-flex; align-items:center; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); border:1px solid var(--c-line); background:var(--c-surface); padding:10px 16px; text-decoration:none; border-radius:2px">Zurück</a>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:11px 15px; border-radius:2px">1</span>
|
||||||
|
<a href="#" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); border:1px solid var(--c-line); background:var(--c-surface); padding:11px 15px; text-decoration:none; border-radius:2px">2</span>
|
||||||
|
<a href="#" class="fa-link" style="min-height:44px; display:inline-flex; align-items:center; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); border:1px solid var(--c-line); background:var(--c-surface); padding:10px 16px; text-decoration:none; border-radius:2px">Weiter</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- CONFIRM DIALOG OVERLAY (destructive delete) — bg-black/20, NO blur (§8) -->
|
||||||
|
<div style="position:fixed; inset:0; z-index:100; background:rgba(0,0,0,.2); display:flex; align-items:center; justify-content:center; padding:24px">
|
||||||
|
<div style="width:100%; max-width:440px; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-md); border-radius:2px; padding:28px 26px">
|
||||||
|
<h2 style="font-family:var(--font-serif); font-size:24px; font-weight:700; color:var(--c-ink); margin:0 0 10px; line-height:1.2">Person löschen</h2>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink-2); margin:0 0 22px; line-height:1.55">Diese Person wird endgültig gelöscht. Dokumentverweise bleiben erhalten, verlieren aber diese Person.</p>
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:10px">
|
||||||
|
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Abbrechen</button>
|
||||||
|
<button class="fa-link" style="background:var(--c-danger); color:#fff; border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
rows(){
|
||||||
|
// Each row shows a different triage state across the list.
|
||||||
|
return [
|
||||||
|
{ name:'Margarethe Cram', docs:5, firstSeen:'03.1916', rename:true, merge:false }, // UMBENENNEN expanded
|
||||||
|
{ name:'Unbekannter Absender', docs:8, firstSeen:'07.1917', rename:false, merge:false }, // IDLE
|
||||||
|
{ name:'H. Cram', docs:12, firstSeen:'11.1914', rename:false, merge:true }, // ZUSAMMENFÜHREN expanded
|
||||||
|
{ name:'Eugenie de Gruyter', docs:3, firstSeen:'02.1920', rename:false, merge:false }, // IDLE
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
return { rows: this.rows() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="personen" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Verzeichnis</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Personen</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Jede Hand, die schrieb oder genannt wurde — Absender, Empfänger, Erwähnte.</p>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">38 Personen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- search + filter -->
|
||||||
|
<div style="display:flex; align-items:center; gap:14px; margin-bottom:24px; flex-wrap:wrap">
|
||||||
|
<div style="position:relative; flex:1; min-width:240px">
|
||||||
|
<input placeholder="z.B. Oma Frieda, Onkel Karl…" style="width:100%; box-sizing:border-box; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:15px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||||
|
</div>
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line)">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:10px 16px; cursor:pointer">Alle</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 16px; border-left:1px solid var(--c-line); cursor:pointer">Personen</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 16px; border-left:1px solid var(--c-line); cursor:pointer">Institutionen</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 16px; border-left:1px solid var(--c-line); cursor:pointer">Gruppen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:18px">
|
||||||
|
<sc-for list="{{ people }}" as="p" hint-placeholder-count="9">
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); padding:20px">
|
||||||
|
<div style="display:flex; align-items:flex-start; gap:14px">
|
||||||
|
<span style="{{ p.a48 }}">{{ p.initials }}</span>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<h3 style="font-family:var(--font-serif); font-size:19px; font-weight:700; color:var(--c-ink); margin:0; line-height:1.2">{{ p.name }}</h3>
|
||||||
|
<div style="margin-top:3px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ p.relation }}</div>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ p.badge }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="{{ p.badge.style }}">{{ p.badge.label }}</span>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="height:1px; background:var(--c-line-2); margin:16px 0"></div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
<span>{{ p.letters }} Briefe</span><span>·</span><span>{{ p.docCount }} Dokumente</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name, a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
people(){
|
||||||
|
const badge = (type) => {
|
||||||
|
const map = {
|
||||||
|
Institution:{ bg:'var(--c-badge-institution-bg)', tc:'var(--c-badge-institution-text)', bd:'var(--c-badge-institution-border)' },
|
||||||
|
Gruppe:{ bg:'var(--c-badge-group-bg)', tc:'var(--c-badge-group-text)', bd:'var(--c-badge-group-border)' },
|
||||||
|
Unbekannt:{ bg:'var(--c-badge-unknown-bg)', tc:'var(--c-badge-unknown-text)', bd:'var(--c-badge-unknown-border)' },
|
||||||
|
};
|
||||||
|
const m = map[type]; if(!m) return null;
|
||||||
|
return { label:type, style:{ flexShrink:0, alignSelf:'flex-start', whiteSpace:'nowrap', background:m.bg, color:m.tc, border:'1px solid '+m.bd, padding:'3px 9px', borderRadius:4, fontFamily:'var(--font-sans)', fontSize:10, fontWeight:700, letterSpacing:'.12em', textTransform:'uppercase' } };
|
||||||
|
};
|
||||||
|
const raw = [
|
||||||
|
{ name:'Frieda Rose', relation:'Großmutter', letters:42, docs:64, type:'Person' },
|
||||||
|
{ name:'Heinrich Rose', relation:'Großvater', letters:31, docs:48, type:'Person' },
|
||||||
|
{ name:'Anna Bauer', relation:'Schwester von Frieda', letters:23, docs:29, type:'Person' },
|
||||||
|
{ name:'Wilhelm Rose', relation:'Sohn', letters:18, docs:22, type:'Person' },
|
||||||
|
{ name:'Elise Vogt', relation:'Freundin der Familie', letters:11, docs:14, type:'Person' },
|
||||||
|
{ name:'Karl Müller', relation:'Geschäftspartner', letters:9, docs:12, type:'Person' },
|
||||||
|
{ name:'Margarete Hoffmann', relation:'Nachbarin', letters:6, docs:8, type:'Person' },
|
||||||
|
{ name:'Otto Schmidt', relation:'Notar in Wien', letters:4, docs:5, type:'Person' },
|
||||||
|
{ name:'Kaufhaus Mariahilf', relation:'Wien, VI. Bezirk', letters:3, docs:3, type:'Institution' },
|
||||||
|
{ name:'Familie Rose', relation:'Sammeleintrag', letters:0, docs:31, type:'Gruppe' },
|
||||||
|
{ name:'Unbekannter Absender', relation:'nicht zugeordnet', letters:5, docs:5, type:'Unbekannt' },
|
||||||
|
];
|
||||||
|
return raw.map(p => ({ ...this.av(p.name), relation:p.relation, letters:p.letters, docCount:p.docs, badge:badge(p.type) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
return { people: this.people() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
180
design_handoff_familienarchiv_redesign/prototypes/Profil.dc.html
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="profil" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- ============ VARIANT: profil (own settings) ============ -->
|
||||||
|
<sc-if value="{{ isProfil }}" hint-placeholder-val="{{ true }}">
|
||||||
|
|
||||||
|
<!-- PageHeader (§3) -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Konto</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Profil</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Verwalten Sie Ihre persönlichen Daten, Ihr Passwort und Ihre Benachrichtigungen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success banner (token-driven: accent bg + accent border) -->
|
||||||
|
<div style="margin-bottom:24px; display:flex; align-items:center; gap:10px; background:var(--c-accent-bg); border:1px solid var(--c-accent); border-radius:2px; padding:12px 16px">
|
||||||
|
<img class="dgicon" src="assets/icons/Check-MD.svg" style="width:16px; height:16px; opacity:.5">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-2)">Ihre Änderungen wurden gespeichert.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2-col card grid -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(2, 1fr); gap:24px">
|
||||||
|
|
||||||
|
<!-- Persönliche Daten -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Persönliche Daten</h2>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Vorname</span>
|
||||||
|
<input value="Marcel" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Nachname</span>
|
||||||
|
<input value="Raddatz" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">E-Mail</span>
|
||||||
|
<input type="email" value="marcel@raddatz.cloud" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="fa-link" style="margin-top:20px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Passwort ändern -->
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Passwort ändern</h2>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px">
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Aktuelles Passwort</span>
|
||||||
|
<input type="password" value="passwort123" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Neues Passwort</span>
|
||||||
|
<input type="password" value="passwort123" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block">
|
||||||
|
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Neues Passwort bestätigen</span>
|
||||||
|
<input type="password" value="passwort123" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="fa-link" style="margin-top:20px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Passwort ändern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Benachrichtigungen -->
|
||||||
|
<div style="margin-top:24px; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||||
|
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 12px">Benachrichtigungen</h2>
|
||||||
|
|
||||||
|
<label class="fa-link" style="display:flex; align-items:center; gap:12px; min-height:44px; cursor:pointer; border-bottom:1px solid var(--c-line-2)">
|
||||||
|
<input type="checkbox" checked style="width:16px; height:16px; accent-color:var(--c-primary); flex-shrink:0">
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">E-Mail bei Antworten auf meine Beiträge</span>
|
||||||
|
</label>
|
||||||
|
<label class="fa-link" style="display:flex; align-items:center; gap:12px; min-height:44px; cursor:pointer">
|
||||||
|
<input type="checkbox" style="width:16px; height:16px; accent-color:var(--c-primary); flex-shrink:0">
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">E-Mail, wenn ich erwähnt werde</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="fa-link" style="margin-top:16px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ============ VARIANT: oeffentlich (public profile) ============ -->
|
||||||
|
<sc-if value="{{ isOeffentlich }}" hint-placeholder-val="{{ false }}">
|
||||||
|
|
||||||
|
<!-- PageHeader (§3) — title = user name -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Mitglied</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">{{ av.name }}</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Mitglied des Familienarchivs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Centered profile card -->
|
||||||
|
<div style="max-width:480px; margin:0 auto">
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:32px 28px">
|
||||||
|
<!-- Avatar (§5 deterministic, a48) -->
|
||||||
|
<div style="display:flex; justify-content:center; margin-bottom:18px">
|
||||||
|
<span style="{{ av.a48 }}">{{ av.initials }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Name (Tinos) -->
|
||||||
|
<div style="text-align:center; margin-bottom:24px">
|
||||||
|
<h2 style="font-family:var(--font-serif); font-size:24px; font-weight:700; color:var(--c-ink); margin:0">{{ av.name }}</h2>
|
||||||
|
</div>
|
||||||
|
<!-- Field rows: caps eyebrow label over Tinos value -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:18px">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:4px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3)">E-Mail</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">marcel@raddatz.cloud</span>
|
||||||
|
</div>
|
||||||
|
<div style="height:1px; background:var(--c-line-2)"></div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:4px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3)">Kontakt</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">Berlin · erreichbar per E-Mail</span>
|
||||||
|
</div>
|
||||||
|
<div style="height:1px; background:var(--c-line-2)"></div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:4px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3)">Beigetreten</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">März 2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"variant":"profil"}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
renderVals(){
|
||||||
|
const variant = this.props.variant || 'profil';
|
||||||
|
return {
|
||||||
|
isProfil: variant === 'profil',
|
||||||
|
isOeffentlich: variant === 'oeffentlich',
|
||||||
|
av: this.av('Marcel Raddatz')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
130
design_handoff_familienarchiv_redesign/prototypes/Regeln.dc.html
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="regeln" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<div style="margin-bottom:36px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-2); display:inline-block; border-bottom:2px solid var(--c-accent); padding-bottom:6px; margin-bottom:18px">Vereinheitlichtes System</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-size:44px; font-weight:700; line-height:1.08; margin:0 0 12px; color:var(--c-ink)">Ein Regelwerk für das Familienarchiv</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-size:18px; line-height:1.6; color:var(--c-ink-2); max-width:640px; margin:0">Sieben Bausteine, die jede Seite teilt — damit Titel, Steuerung, Karten und Metadaten überall gleich klingen. Diese Richtung — „Mappe" — liegt allen Seiten zugrunde.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(2, 1fr); gap:20px">
|
||||||
|
|
||||||
|
<!-- Typografie -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); padding:24px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:18px">01 — Typografie</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:14px">
|
||||||
|
<div style="display:flex; align-items:baseline; justify-content:space-between; gap:16px; border-bottom:1px solid var(--c-line-2); padding-bottom:14px">
|
||||||
|
<span style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink)">Seitentitel</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">Tinos · Serif</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:baseline; justify-content:space-between; gap:16px; border-bottom:1px solid var(--c-line-2); padding-bottom:14px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink)">Rubrik / Label</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">Montserrat · Versal</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:baseline; justify-content:space-between; gap:16px; border-bottom:1px solid var(--c-line-2); padding-bottom:14px">
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; line-height:1.6; color:var(--c-ink)">Fließtext, Briefinhalt und Transkription</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">Tinos · 16</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:baseline; justify-content:space-between; gap:16px">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">14. März 1923 · 4 Personen</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">Montserrat · Meta</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Farbe -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); padding:24px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:18px">02 — Farbe</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px"><span style="width:28px; height:28px; background:#012851; border:1px solid var(--c-line)"></span><span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">Navy — Tinte & Primärfläche</span></div>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px"><span style="width:28px; height:28px; background:#f0efe9; border:1px solid var(--c-line)"></span><span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">Sand — Hintergrund</span></div>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px"><span style="width:28px; height:28px; background:#a1dcd8; border:1px solid var(--c-line)"></span><span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">Mint — nur Linie, Streifen, Unterton</span></div>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px"><span style="width:28px; height:28px; background:#00c7b1; border:1px solid var(--c-line)"></span><span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">Türkis — Transkriptionsmodus</span></div>
|
||||||
|
</div>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:13px; color:var(--c-ink-3); margin:16px 0 0">Keine Verläufe. Mint trägt nie Text.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Seitenkopf -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); padding:24px; grid-column:1 / -1">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:18px">03 — Seitenkopf</div>
|
||||||
|
<div style="padding:20px; background:var(--c-muted); border:1px solid var(--c-line-2); max-width:520px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:10px">Geschichten</div>
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:14px"><div style="font-family:var(--font-serif); font-size:34px; font-weight:700; line-height:1.05; color:var(--c-ink)">Seitentitel</div></div>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:12px 0 0">Rubrik in Versalien, großer Serif-Titel, Mint-Steg links. Rechts steht die Zählung.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Steuerung -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); padding:24px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:18px">04 — Steuerung</div>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2); margin:0 0 14px">Segmentgruppe für Filter & Ansichten</p>
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line)">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:8px 14px">Alle</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:8px 14px; border-left:1px solid var(--c-line)">Veröffentlicht</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:8px 14px; border-left:1px solid var(--c-line)">Entwurf</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Avatare -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); padding:24px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:18px">05 — Personen & Avatare</div>
|
||||||
|
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px">
|
||||||
|
<span style="width:36px; height:36px; border-radius:999px; background:#5a8a6a; color:#fff; display:flex; align-items:center; justify-content:center; font-family:var(--font-sans); font-size:13px; font-weight:700">FR</span>
|
||||||
|
<span style="width:36px; height:36px; border-radius:999px; background:#7a4f9a; color:#fff; display:flex; align-items:center; justify-content:center; font-family:var(--font-sans); font-size:13px; font-weight:700">KM</span>
|
||||||
|
<span style="width:36px; height:36px; border-radius:999px; background:#3060b0; color:#fff; display:flex; align-items:center; justify-content:center; font-family:var(--font-sans); font-size:13px; font-weight:700">AB</span>
|
||||||
|
<span style="width:36px; height:36px; border-radius:999px; background:#c17a00; color:#fff; display:flex; align-items:center; justify-content:center; font-family:var(--font-sans); font-size:13px; font-weight:700">WR</span>
|
||||||
|
<span style="width:36px; height:36px; border-radius:999px; background:#c0446e; color:#fff; display:flex; align-items:center; justify-content:center; font-family:var(--font-sans); font-size:13px; font-weight:700">MH</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:14px; color:var(--c-ink-2); margin:0; line-height:1.6">Die Farbe wird je Person aus dem Namen berechnet und bleibt stabil — sie unterscheidet, sie schmückt nicht. Form immer rund, Initialen in Montserrat.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Metadaten & Leerzustände -->
|
||||||
|
<section style="background:var(--c-surface); border:1px solid var(--c-line); padding:24px; grid-column:1 / -1">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:18px">06 — Metadaten & Leerzustände</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:24px">
|
||||||
|
<div>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2); margin:0 0 12px">Eine Metazeile, immer gleich getrennt mit ·</p>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">
|
||||||
|
<img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
<span>14. März 1923</span><span>·</span><span>14 Dokumente</span><span>·</span><span>4 Personen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-left:1px solid var(--c-line); padding-left:24px">
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2); margin:0 0 12px">Leerzustand — ruhig, Sie-Form, mit Auslassung</p>
|
||||||
|
<div style="border:1px dashed var(--c-line); padding:18px; text-align:center">
|
||||||
|
<p style="font-family:var(--font-serif); font-size:15px; color:var(--c-ink-2); margin:0">Noch keine Geschichten angelegt.</p>
|
||||||
|
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:6px 0 0">Beginnen Sie mit einem Brief…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="stammbaum" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- §3 PageHeader -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Stammbaum</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Familie Cram</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Drei Generationen, verwoben durch Brief und Blut — wählen Sie eine Person, um ihre Linie zu verfolgen.</p>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">38 Personen · 4 Generationen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tree + side panel -->
|
||||||
|
<div style="display:flex; gap:20px; align-items:flex-start; flex-wrap:wrap">
|
||||||
|
|
||||||
|
<!-- CANVAS: relative-positioned area holding absolutely-positioned node cards -->
|
||||||
|
<div style="position:relative; flex:1; min-width:480px; height:540px; background:var(--c-muted); border:1px solid var(--c-line); border-radius:2px; overflow:hidden">
|
||||||
|
|
||||||
|
<!-- Connector lines, drawn beneath the node cards -->
|
||||||
|
<svg viewBox="0 0 760 540" preserveAspectRatio="none" style="position:absolute; inset:0; width:100%; height:100%; pointer-events:none">
|
||||||
|
<!-- Gen 1 couple bar (Friedrich + Eugenie) -->
|
||||||
|
<line x1="170" y1="76" x2="430" y2="76" stroke="var(--c-line)" stroke-width="1.5"></line>
|
||||||
|
<!-- Drop from Gen 1 couple to the Gen 2 sibling bar -->
|
||||||
|
<line x1="300" y1="76" x2="300" y2="208" stroke="var(--c-line)" stroke-width="1.5"></line>
|
||||||
|
<!-- Gen 2 sibling bar (Herbert · Clara · Marie) -->
|
||||||
|
<line x1="110" y1="208" x2="540" y2="208" stroke="var(--c-line)" stroke-width="1.5"></line>
|
||||||
|
<!-- Drops onto each Gen 2 child -->
|
||||||
|
<line x1="110" y1="208" x2="110" y2="236" stroke="var(--c-line)" stroke-width="1.5"></line>
|
||||||
|
<line x1="300" y1="208" x2="300" y2="236" stroke="var(--c-line)" stroke-width="1.5"></line>
|
||||||
|
<line x1="540" y1="208" x2="540" y2="236" stroke="var(--c-line)" stroke-width="1.5"></line>
|
||||||
|
<!-- Gen 2 marriage bar (Herbert + Wilhelmine) -->
|
||||||
|
<line x1="110" y1="276" x2="240" y2="276" stroke="var(--c-line)" stroke-width="1.5"></line>
|
||||||
|
<!-- Drop to Gen 3 sibling bar -->
|
||||||
|
<line x1="175" y1="276" x2="175" y2="404" stroke="var(--c-accent)" stroke-width="2"></line>
|
||||||
|
<!-- Gen 3 sibling bar (Gertrud · Karl) -->
|
||||||
|
<line x1="110" y1="404" x2="300" y2="404" stroke="var(--c-accent)" stroke-width="2"></line>
|
||||||
|
<line x1="110" y1="404" x2="110" y2="432" stroke="var(--c-accent)" stroke-width="2"></line>
|
||||||
|
<line x1="300" y1="404" x2="300" y2="432" stroke="var(--c-accent)" stroke-width="2"></line>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Generation rail labels -->
|
||||||
|
<div style="position:absolute; left:14px; top:44px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3)">G 1</div>
|
||||||
|
<div style="position:absolute; left:14px; top:236px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3)">G 2</div>
|
||||||
|
<div style="position:absolute; left:14px; top:432px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3)">G 3</div>
|
||||||
|
|
||||||
|
<!-- NODE CARDS (absolutely positioned via the data) -->
|
||||||
|
<sc-for list="{{ nodes }}" as="n" hint-placeholder-count="8">
|
||||||
|
<div style="{{ n.cardStyle }}">
|
||||||
|
<span style="{{ n.av28 }}">{{ n.initials }}</span>
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:14px; font-weight:700; color:{{ n.nameColor }}; line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis">{{ n.name }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:10px; letter-spacing:.04em; color:{{ n.dateColor }}; margin-top:1px">{{ n.dates }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<!-- Zoom controls: vertical control group, bottom-right, 44px targets -->
|
||||||
|
<div style="position:absolute; right:16px; bottom:16px; display:flex; flex-direction:column; gap:1px; border:1px solid var(--c-line); border-radius:2px; overflow:hidden; box-shadow:var(--shadow-sm)">
|
||||||
|
<button class="fa-link" title="Vergrößern" style="width:44px; height:44px; display:flex; align-items:center; justify-content:center; border:none; background:var(--c-surface); cursor:pointer; font-family:var(--font-sans); font-size:20px; font-weight:700; color:var(--c-ink-2); line-height:1">+</button>
|
||||||
|
<button class="fa-link" title="Verkleinern" style="width:44px; height:44px; display:flex; align-items:center; justify-content:center; border:none; border-top:1px solid var(--c-line); background:var(--c-surface); cursor:pointer; font-family:var(--font-sans); font-size:20px; font-weight:700; color:var(--c-ink-2); line-height:1">−</button>
|
||||||
|
<button class="fa-link" title="Ansicht einpassen" style="width:44px; height:44px; display:flex; align-items:center; justify-content:center; border:none; border-top:1px solid var(--c-line); background:var(--c-surface); cursor:pointer">
|
||||||
|
<img class="dgicon" src="assets/icons/View-More-MD.svg" style="width:18px; height:18px; opacity:.45">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SIDE PANEL: selected node detail (3px mint top border) -->
|
||||||
|
<aside style="width:320px; flex-shrink:0; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:22px">
|
||||||
|
<!-- Header: large avatar + name + dates -->
|
||||||
|
<div style="display:flex; align-items:center; gap:14px; margin-bottom:18px">
|
||||||
|
<span style="{{ selected.a48 }}">{{ selected.initials }}</span>
|
||||||
|
<div style="min-width:0">
|
||||||
|
<h2 style="font-family:var(--font-serif); font-size:20px; font-weight:700; color:var(--c-ink); margin:0; line-height:1.2">{{ selected.name }}</h2>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin-top:2px">{{ selected.dates }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Direct relationships as tag-style chips (dot + label, 4px radius) -->
|
||||||
|
<h3 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 12px">Direkte Beziehungen</h3>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:22px">
|
||||||
|
<sc-for list="{{ chips }}" as="c" hint-placeholder-count="4">
|
||||||
|
<div style="display:flex; align-items:center; gap:9px">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px; background:var(--c-muted); border:1px solid var(--c-line); border-radius:4px; padding:3px 9px; flex-shrink:0">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:{{ c.dot }}"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ c.label }}</span>
|
||||||
|
</span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:14px; color:var(--c-ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis">{{ c.person }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta line (§8) -->
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2); margin-bottom:18px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
<span>{{ selected.letters }} Briefe</span><span>·</span><span>{{ selected.docs }} Dokumente</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/persons/{{ selected.id }}" class="fa-link" style="display:inline-flex; align-items:center; gap:6px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-primary); text-decoration:none">
|
||||||
|
Zur Person
|
||||||
|
<img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state (alternate — shown when no tree exists yet) -->
|
||||||
|
<div style="margin-top:32px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:10px">Alternativ — leerer Zustand</div>
|
||||||
|
<div style="border:1px dashed var(--c-line); border-radius:2px; padding:48px 24px; text-align:center">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:20px; color:var(--c-ink); margin-bottom:8px">Noch kein Stammbaum vorhanden.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Verknüpfen Sie zwei Personen, um die erste Linie zu zeichnen…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one absolutely-positioned node card. state ∈ 'rest' | 'selected' | 'dim'.
|
||||||
|
nodeCard(name, dates, x, y, state){
|
||||||
|
const a = this.av(name);
|
||||||
|
const NODE_W = 168;
|
||||||
|
const card = {
|
||||||
|
position:'absolute', left:x+'px', top:y+'px', width:NODE_W+'px',
|
||||||
|
display:'flex', alignItems:'center', gap:'10px',
|
||||||
|
background:'var(--c-surface)', border:'1px solid var(--c-line)',
|
||||||
|
borderRadius:'2px', padding:'9px 11px', boxShadow:'var(--shadow-sm)', cursor:'pointer'
|
||||||
|
};
|
||||||
|
let nameColor = 'var(--c-ink)';
|
||||||
|
let dateColor = 'var(--c-ink-3)';
|
||||||
|
if (state === 'selected') {
|
||||||
|
card.background = 'var(--c-muted)';
|
||||||
|
card.border = '1px solid var(--c-accent)';
|
||||||
|
card.borderLeft = '3px solid var(--c-accent)';
|
||||||
|
card.paddingLeft = '9px';
|
||||||
|
} else if (state === 'dim') {
|
||||||
|
card.opacity = 0.45;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name, dates, initials:a.initials, av28:a.a28,
|
||||||
|
cardStyle:card, nameColor, dateColor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes(){
|
||||||
|
// 3 generations, 8 nodes. Highlighted lineage = Herbert → Gertrud/Karl line.
|
||||||
|
// Selected: Herbert Cram. Dimmed: out-of-lineage (the non-ancestor branches).
|
||||||
|
return [
|
||||||
|
// Gen 1 — grandparents
|
||||||
|
this.nodeCard('Friedrich Cram', '1868–1934', 6, 42, 'rest'),
|
||||||
|
this.nodeCard('Eugenie de Gruyter','1872–1948', 262, 42, 'dim'),
|
||||||
|
// Gen 2 — children + the spouse married in
|
||||||
|
this.nodeCard('Herbert Cram', '1899–1945', 26, 242, 'selected'),
|
||||||
|
this.nodeCard('Clara Cram', '1902–1989', 216, 242, 'dim'),
|
||||||
|
this.nodeCard('Marie Cram', '1905–1992', 456, 242, 'dim'),
|
||||||
|
this.nodeCard('Wilhelmine Voß', '1903–1981', 156, 242, 'dim'),
|
||||||
|
// Gen 3 — grandchildren
|
||||||
|
this.nodeCard('Gertrud Cram', '1928–2011', 26, 438, 'rest'),
|
||||||
|
this.nodeCard('Karl Cram', '1931–1998', 216, 438, 'rest'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
selected(){
|
||||||
|
const a = this.av('Herbert Cram');
|
||||||
|
return { id:'herbert-cram', name:'Herbert Cram', dates:'1899–1945', initials:a.initials, a48:a.a48, letters:42, docs:64 };
|
||||||
|
}
|
||||||
|
|
||||||
|
chips(){
|
||||||
|
// Tag-style chips: colored dot + UPPERCASE label + person name.
|
||||||
|
return [
|
||||||
|
{ label:'Vater', dot:this.av('Friedrich Cram').bg, person:'Friedrich Cram' },
|
||||||
|
{ label:'Mutter', dot:this.av('Eugenie de Gruyter').bg, person:'Eugenie de Gruyter' },
|
||||||
|
{ label:'Ehefrau', dot:this.av('Wilhelmine Voß').bg, person:'Wilhelmine Voß' },
|
||||||
|
{ label:'Tochter', dot:this.av('Gertrud Cram').bg, person:'Gertrud Cram' },
|
||||||
|
{ label:'Sohn', dot:this.av('Karl Cram').bg, person:'Karl Cram' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
return { nodes: this.nodes(), selected: this.selected(), chips: this.chips() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
190
design_handoff_familienarchiv_redesign/prototypes/Themen.dc.html
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button,textarea,select{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="themen" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||||
|
|
||||||
|
<!-- PageHeader (§3) -->
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Themen</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Themen</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Worüber die Familie schrieb — Schlagworte, nach denen Sie die Sammlung durchstöbern können.</p>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">24 Themen · 312 Dokumente</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Segmented control (§6) -->
|
||||||
|
<div style="margin-bottom:28px; display:inline-flex; border:1px solid var(--c-line)">
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:10px 16px; cursor:pointer">Alle</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 16px; border-left:1px solid var(--c-line); cursor:pointer">Mit Dokumenten</span>
|
||||||
|
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 16px; border-left:1px solid var(--c-line); cursor:pointer">Alphabetisch</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tag-card grid -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:18px">
|
||||||
|
<sc-for list="{{ topics }}" as="t" hint-placeholder-count="9">
|
||||||
|
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; overflow:hidden">
|
||||||
|
|
||||||
|
<!-- parent-tag row -->
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:11px; min-height:56px; padding:14px 16px 12px; text-decoration:none">
|
||||||
|
<span style="{{ t.dot }}"></span>
|
||||||
|
<span style="font-family:var(--font-serif); font-size:19px; font-weight:700; color:var(--c-ink); line-height:1.2; flex:1; min-width:0">{{ t.name }}</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">{{ t.count }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div style="height:1px; background:var(--c-line-2); margin:0 16px"></div>
|
||||||
|
|
||||||
|
<!-- child-tag rows -->
|
||||||
|
<sc-for list="{{ t.children }}" as="c" hint-placeholder-count="4">
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:10px; min-height:44px; padding:8px 16px; text-decoration:none">
|
||||||
|
<span style="{{ c.dot }}"></span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:14px; color:var(--c-ink); flex:1; min-width:0">{{ c.name }}</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ c.count }}</span>
|
||||||
|
</a>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<!-- + N weitere -->
|
||||||
|
<sc-if value="{{ t.more }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:8px; min-height:44px; padding:8px 16px; text-decoration:none; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2)">
|
||||||
|
<span>{{ t.more }} weitere</span>
|
||||||
|
<img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:14px; height:14px; opacity:.4">
|
||||||
|
</a>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<!-- empty state (§10) -->
|
||||||
|
<div style="border:1px dashed var(--c-line); border-radius:2px; padding:48px 24px; text-align:center; display:flex; flex-direction:column; align-items:center; justify-content:center">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:20px; color:var(--c-ink); margin-bottom:8px">Noch kein Thema vergeben.</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Verschlagworten Sie einen Brief, um hier ein Thema anzulegen…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
av(name){
|
||||||
|
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||||
|
const bg = pal[h % pal.length];
|
||||||
|
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||||
|
return { bg, initials, name,
|
||||||
|
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||||
|
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||||
|
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||||
|
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// deterministic-ish tag color from the §1 list, keyed by the topic name
|
||||||
|
tagColor(name){
|
||||||
|
const tokens = ['sage','sienna','amber','slate','violet','rose','cobalt','moss','sand-tag','coral'];
|
||||||
|
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||||
|
return 'var(--c-tag-' + tokens[h % tokens.length] + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
dot(name, size){
|
||||||
|
return { display:'inline-block', width:size, height:size, borderRadius:'999px', background:this.tagColor(name), flexShrink:0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
topics(){
|
||||||
|
const raw = [
|
||||||
|
{ name:'Krieg & Feldpost', count:74, children:[
|
||||||
|
{ name:'Feldpostbriefe', count:41 },
|
||||||
|
{ name:'Lazarett & Verwundung', count:12 },
|
||||||
|
{ name:'Gefangenschaft', count:9 },
|
||||||
|
{ name:'Heimaturlaub', count:7 },
|
||||||
|
{ name:'Vermisstenmeldungen', count:5 },
|
||||||
|
], total:11 },
|
||||||
|
{ name:'Familie & Kinder', count:58, children:[
|
||||||
|
{ name:'Geburt & Taufe', count:14 },
|
||||||
|
{ name:'Schule & Ausbildung', count:11 },
|
||||||
|
{ name:'Großeltern', count:9 },
|
||||||
|
{ name:'Erbschaft', count:6 },
|
||||||
|
{ name:'Patenschaft', count:4 },
|
||||||
|
], total:8 },
|
||||||
|
{ name:'Orte', count:46, children:[
|
||||||
|
{ name:'Wien', count:18 },
|
||||||
|
{ name:'Berlin', count:12 },
|
||||||
|
{ name:'Harz', count:7 },
|
||||||
|
{ name:'München', count:5 },
|
||||||
|
{ name:'Triest', count:4 },
|
||||||
|
], total:9 },
|
||||||
|
{ name:'Geschäft & Handel', count:33, children:[
|
||||||
|
{ name:'Kaufhaus Mariahilf', count:11 },
|
||||||
|
{ name:'Rechnungen', count:8 },
|
||||||
|
{ name:'Verträge', count:6 },
|
||||||
|
] },
|
||||||
|
{ name:'Reisen', count:29, children:[
|
||||||
|
{ name:'Italienreise 1924', count:9 },
|
||||||
|
{ name:'Kuraufenthalt', count:7 },
|
||||||
|
{ name:'Postkarten', count:13 },
|
||||||
|
] },
|
||||||
|
{ name:'Krankheit & Gesundheit', count:24, children:[
|
||||||
|
{ name:'Sanatorium', count:8 },
|
||||||
|
{ name:'Arztbriefe', count:6 },
|
||||||
|
{ name:'Genesung', count:5 },
|
||||||
|
] },
|
||||||
|
{ name:'Feiertage', count:21, children:[
|
||||||
|
{ name:'Weihnachten', count:12 },
|
||||||
|
{ name:'Ostern', count:5 },
|
||||||
|
{ name:'Geburtstage', count:4 },
|
||||||
|
] },
|
||||||
|
{ name:'Liebe & Verlobung', count:17, children:[
|
||||||
|
{ name:'Liebesbriefe', count:9 },
|
||||||
|
{ name:'Verlobung', count:5 },
|
||||||
|
{ name:'Hochzeit', count:3 },
|
||||||
|
] },
|
||||||
|
{ name:'Behörde & Recht', count:14, children:[
|
||||||
|
{ name:'Notariat', count:6 },
|
||||||
|
{ name:'Meldewesen', count:5 },
|
||||||
|
{ name:'Steuern', count:3 },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX = 5;
|
||||||
|
return raw.map(t => {
|
||||||
|
const shown = t.children.slice(0, MAX);
|
||||||
|
const total = t.total || t.children.length;
|
||||||
|
const more = total - shown.length;
|
||||||
|
return {
|
||||||
|
name: t.name,
|
||||||
|
count: t.count,
|
||||||
|
dot: this.dot(t.name, 7),
|
||||||
|
children: shown.map(c => ({ name:c.name, count:c.count, dot:this.dot(c.name, 6) })),
|
||||||
|
more: more > 0 ? more : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
return { topics: this.topics() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;background:#f0efe9}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
input,button{font-family:inherit}
|
||||||
|
::selection{background:#a1dcd8;color:#012851}
|
||||||
|
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||||
|
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||||
|
</style>
|
||||||
|
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||||
|
<dc-import name="ArchiveHeader" active="zeitstrahl" hint-size="100%,68px"></dc-import>
|
||||||
|
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 90px">
|
||||||
|
|
||||||
|
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||||
|
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Chronik</div>
|
||||||
|
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Zeitstrahl</h1>
|
||||||
|
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Briefdichte, Lebensdaten und kuratierte Stationen — die Sammlung im Lauf der Jahre.</p>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">147 Dokumente · 1914–1948</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- filter -->
|
||||||
|
<div style="margin-bottom:38px; display:flex; align-items:center; gap:16px; flex-wrap:wrap">
|
||||||
|
<div style="display:inline-flex; border:1px solid var(--c-line)">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 16px; cursor:pointer">Alle</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Briefe</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Personen</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Ereignisse</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:16px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px"><span style="width:10px; height:10px; border-radius:999px; background:var(--c-primary)"></span>Person</span>
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px"><span style="width:10px; height:10px; border-radius:2px; background:var(--c-accent)"></span>Kuratiert</span>
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:6px"><img class="dgicon" src="assets/icons/Globe-MD.svg" style="width:12px; height:12px; opacity:.4">Historisch</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- spine -->
|
||||||
|
<div style="position:relative; max-width:760px; margin:0 auto">
|
||||||
|
<div style="position:absolute; left:50%; top:0; bottom:0; width:2px; background:var(--c-accent); transform:translateX(-50%)"></div>
|
||||||
|
<div style="position:relative; display:flex; flex-direction:column; align-items:center; gap:18px">
|
||||||
|
<sc-for list="{{ items }}" as="it" hint-placeholder-count="10">
|
||||||
|
|
||||||
|
<sc-if value="{{ it.isYear }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="background:var(--c-primary); color:var(--c-primary-fg); font-family:var(--font-sans); font-size:13px; font-weight:700; letter-spacing:.1em; padding:6px 16px; border-radius:2px">{{ it.label }}</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ it.isSummary }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); padding:22px 24px">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:18px">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:18px; height:18px; opacity:.55">
|
||||||
|
<span style="font-family:var(--font-sans); font-size:18px; font-weight:700; color:var(--c-ink)">{{ it.count }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">Briefe anzeigen →</a>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:5px; height:60px">
|
||||||
|
<sc-for list="{{ it.bars }}" as="bar" hint-placeholder-count="12">
|
||||||
|
<div style="{{ bar.style }}"></div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-top:8px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">
|
||||||
|
<span>{{ it.from }}</span><span>Monats-Dichte</span><span>{{ it.to }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ it.isLetter }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="{{ it.rowStyle }}">
|
||||||
|
<div style="{{ it.cardStyle }}">
|
||||||
|
<div style="display:flex; align-items:flex-start; gap:9px; margin-bottom:5px">
|
||||||
|
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:15px; height:15px; opacity:.5; flex-shrink:0; margin-top:3px">
|
||||||
|
<span style="font-family:var(--font-serif); font-size:16px; font-weight:700; color:var(--c-ink); line-height:1.3">{{ it.title }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); padding-left:24px">{{ it.meta }}</div>
|
||||||
|
<sc-if value="{{ it.tag }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="display:inline-flex; align-items:center; gap:7px; margin:10px 0 0 24px; background:var(--c-surface); border:1px solid var(--c-line); padding:3px 11px; border-radius:999px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-2)"><span style="{{ it.tag.dot }}"></span>{{ it.tag.label }}</span>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
<span style="{{ it.dotStyle }}"></span>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ it.isPerson }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:12px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; box-shadow:var(--shadow-sm); padding:10px 20px 10px 12px">
|
||||||
|
<span style="width:28px; height:28px; border-radius:999px; background:var(--c-primary); color:var(--c-primary-fg); display:flex; align-items:center; justify-content:center; font-size:13px; flex-shrink:0">{{ it.glyph }}</span>
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--font-serif); font-size:16px; font-weight:700; color:var(--c-ink); line-height:1.2">{{ it.name }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-top:2px">{{ it.meta }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ it.isCurated }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:12px; background:var(--c-surface); border:1px solid var(--c-line); border-left:3px solid var(--c-accent); border-radius:2px; box-shadow:var(--shadow-sm); padding:12px 16px 12px 13px; max-width:100%">
|
||||||
|
<span style="width:28px; height:28px; border-radius:999px; background:var(--c-primary); color:var(--c-primary-fg); display:flex; align-items:center; justify-content:center; font-size:13px; flex-shrink:0">★</span>
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div style="font-family:var(--font-serif); font-size:16px; font-weight:700; color:var(--c-ink); line-height:1.25">{{ it.title }}</div>
|
||||||
|
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-top:2px">{{ it.meta }}</div>
|
||||||
|
</div>
|
||||||
|
<img class="dgicon" src="assets/icons/Bookmarks-MD.svg" style="width:15px; height:15px; opacity:.4; flex-shrink:0">
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ it.isHistorical }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="width:100%; display:flex; align-items:center; justify-content:center; gap:10px; padding:11px 16px; border-top:1px solid var(--c-line-2); border-bottom:1px solid var(--c-line-2); background:var(--c-canvas); flex-wrap:wrap">
|
||||||
|
<img class="dgicon" src="assets/icons/Globe-MD.svg" style="width:15px; height:15px; opacity:.4">
|
||||||
|
<span style="font-family:var(--font-serif); font-style:italic; font-size:15px; color:var(--c-ink-2)">{{ it.title }}</span>
|
||||||
|
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ it.meta }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
bars(values){
|
||||||
|
return values.map(v => ({ style: { flex:'1', height: v + '%', minHeight:'3px', background:'var(--c-accent)', borderRadius:'1px 1px 0 0' } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
items(){
|
||||||
|
const L = (docId, date, place, sender, recipient, tag) => ({ kind:'letter', title:docId+' – '+date+' – '+place, meta:sender+' → '+recipient+' · '+date, tag });
|
||||||
|
return [
|
||||||
|
{ kind:'year', label:'1923' },
|
||||||
|
{ kind:'summary', count:'188 Briefe', from:'Jan. 1923', to:'Dez. 1923', bars:[30,42,38,55,48,60,52,66,58,70,62,75] },
|
||||||
|
L('FR-0142','21. Januar 1923','Wien','Frieda Rose','Anna Bauer',{ label:'Briefwechsel', color:'#3060b0' }),
|
||||||
|
L('AB-0098','30. Januar 1923','Berlin','Anna Bauer','Frieda Rose'),
|
||||||
|
L('HR-1412','16. Februar 1923','Harz','Heinrich Rose','Wilhelm Rose'),
|
||||||
|
L('FR-0143','4. März 1923','Wien','Frieda Rose','Anna Bauer'),
|
||||||
|
L('KM-0211','28. März 1923','Triest','Karl Müller','Frieda Rose',{ label:'Reise', color:'#c17a00' }),
|
||||||
|
{ kind:'person', name:'Elise Vogt', meta:'1923 · abgeleitet', glyph:'✳' },
|
||||||
|
L('FR-0148','21. September 1923','Wien','Frieda Rose','Anna Bauer'),
|
||||||
|
L('MH-0033','4. Dezember 1923','München','Margarete Hoffmann','Heinrich Rose',{ label:'Weihnachten', color:'#c0446e' }),
|
||||||
|
L('FR-0151','7. Dezember 1923','Wien','Frieda Rose','Anna Bauer'),
|
||||||
|
{ kind:'curated', title:'Sommer 1923 – die Briefe aus dem Harz', meta:'Juli 1923 · kuratiert' },
|
||||||
|
{ kind:'year', label:'1924' },
|
||||||
|
{ kind:'summary', count:'271 Briefe', from:'Jan. 1924', to:'Dez. 1924', bars:[40,55,48,70,62,58,75,52,92,60,72,80] },
|
||||||
|
L('OS-0019','19. Februar 1924','Wien','Otto Schmidt','Heinrich Rose',{ label:'Recht', color:'#607080' }),
|
||||||
|
L('KM-0224','2. August 1924','Venedig','Karl Müller','Frieda Rose',{ label:'Reise', color:'#c17a00' }),
|
||||||
|
{ kind:'person', name:'Heinrich Rose', meta:'1924 · abgeleitet', glyph:'†' },
|
||||||
|
L('WR-0061','8. Oktober 1924','Leipzig','Wilhelm Rose','Frieda Rose'),
|
||||||
|
{ kind:'historical', title:'Gründung der Rentenmark', meta:'1924 · historisch' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals(){
|
||||||
|
let letterN = 0;
|
||||||
|
const items = this.items().map(it => {
|
||||||
|
const isLetter = it.kind==='letter';
|
||||||
|
let rowStyle = null, cardStyle = null, dotStyle = null, tag = it.tag || null;
|
||||||
|
if (isLetter){
|
||||||
|
const left = (letterN % 2) === 0; letterN++;
|
||||||
|
rowStyle = { position:'relative', width:'100%', display:'flex', justifyContent: left ? 'flex-start' : 'flex-end' };
|
||||||
|
cardStyle = { width:'46%', background:'var(--c-surface)', border:'1px solid var(--c-line)', boxShadow:'var(--shadow-sm)', borderRadius:'2px', padding:'14px 18px' };
|
||||||
|
dotStyle = { position:'absolute', left:'50%', top:'50%', transform:'translate(-50%,-50%)', width:'13px', height:'13px', borderRadius:'999px', background:'var(--c-surface)', border:'2px solid var(--c-primary)', zIndex:1 };
|
||||||
|
if (tag) tag = { label:tag.label, dot:{ display:'inline-block', width:7, height:7, borderRadius:'999px', background:tag.color, flexShrink:0 } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...it,
|
||||||
|
isYear: it.kind==='year',
|
||||||
|
isSummary: it.kind==='summary',
|
||||||
|
isPerson: it.kind==='person',
|
||||||
|
isCurated: it.kind==='curated',
|
||||||
|
isHistorical: it.kind==='historical',
|
||||||
|
isLetter,
|
||||||
|
rowStyle, cardStyle, dotStyle, tag,
|
||||||
|
bars: it.bars ? this.bars(it.bars) : [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Account-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Account-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M12,6 C14.7614237,6 17,8.23857625 17,11 C17,12.3722504 16.4471943,13.6153861 15.5521301,14.5188599 L17.855,17.452 L19.1030012,19.0389904 L19.1030012,19.0389904 L19.1020055,19.039995 L19.0710678,19.0710678 C18.997228,19.1449077 18.9222387,19.2175981 18.8461293,19.28911 L19.1020055,19.039995 C19.0039559,19.1389028 18.9038547,19.2357734 18.8017708,19.3305377 C18.7171965,19.4090494 18.6311863,19.4861804 18.5438523,19.5618238 C18.5263469,19.5769851 18.5089856,19.5919182 18.4915726,19.6067925 C18.309932,19.7619691 18.122204,19.9110944 17.9291745,20.0534458 C17.9013947,20.0739295 17.8740517,20.0938783 17.8466043,20.1136912 C16.2028871,21.3004163 14.1831418,22 12,22 C9.81685818,22 7.79711289,21.3004163 6.15271505,20.1131998 L6.44094063,20.3137102 C6.31572015,20.2298139 6.19250037,20.1431636 6.07136795,20.0538458 C5.87779602,19.9110944 5.69006803,19.7619691 5.50799235,19.6064208 C5.4910144,19.5919182 5.47365307,19.5769851 5.45634375,19.5619936 C5.36881373,19.4861804 5.28280351,19.4090494 5.19815647,19.3304702 C5.18343312,19.3168025 5.16867872,19.3030231 5.15396618,19.2891997 C5.07776126,19.2175981 5.00277203,19.1449077 4.92893219,19.0710678 C4.91889576,19.0610327 4.9084335,19.0505254 4.8979945,19.039995 L8.44884397,14.5198429 C7.5532068,13.6162879 7,12.3727481 7,11 C7,8.23857625 9.23857625,6 12,6 Z M12,16 C11.3344438,16 10.69926,15.8699605 10.1184702,15.6339033 L7.68110661,18.7351936 C8.9268731,19.5356884 10.4092137,20 12,20 C13.5907863,20 15.0731269,19.5356884 16.3188934,18.7351936 L13.8815298,15.6339033 C13.30074,15.8699605 12.6655562,16 12,16 Z M12,2 C17.5228475,2 22,6.4771525 22,12 C22,14.0067927 21.408873,15.8755204 20.3912443,17.4415574 L19.0653983,15.7557316 C19.6619312,14.635851 20,13.3574056 20,12 C20,7.581722 16.418278,4 12,4 C7.581722,4 4,7.581722 4,12 C4,13.3574056 4.33806879,14.635851 4.93460172,15.7557316 L3.60940596,17.442558 C2.59137888,15.8763164 2,14.0072202 2,12 C2,6.4771525 6.4771525,2 12,2 Z M12,8 C10.3431458,8 9,9.34314575 9,11 C9,12.6568542 10.3431458,14 12,14 C13.6568542,14 15,12.6568542 15,11 C15,9.34314575 13.6568542,8 12,8 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Arrow/Arrow-Right-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Arrow/Arrow-Right-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M8.468,4.00069609 L17,12 L8.468,19.9996961 L7.001,18.6236961 L14.0650029,12 L7.001,5.37669609 L8.468,4.00069609 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Bookmarks-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Bookmarks-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M21.3497504,3.6551887 L21.6777078,3.86309927 L22,4.0853485 L22,18.25 C21.3650372,17.7938243 20.6942363,17.4226854 20.0009587,17.1365834 L20,21.7544657 C18.6666667,20.5544657 17,19.9544657 15,19.9544657 C13.8333333,19.9544657 12.8335071,20.3500061 12.0005212,21.141087 C11.1668404,20.3500061 10.1666667,19.9544657 9,19.9544657 C7,19.9544657 5.33333333,20.5544657 4,21.7544657 L4.00045104,17.1359956 C3.42290002,17.374239 2.8609383,17.671489 2.32229219,18.0277508 L2,18.25 L2,4.0853485 C5.00310218,1.92783294 8.80785971,1.67249293 12.0007123,3.31932848 L12.0277538,3.30470469 C14.9881375,1.78996923 18.4697725,1.90679723 21.3497504,3.6551887 Z M6.00029079,16.5555343 L6,18.3814657 L6.19076597,18.3249848 C6.95960841,18.1089212 7.77427089,17.9872902 8.63050815,17.9602585 L9,17.9544657 C9.70153827,17.9544657 10.3709864,18.0578476 11.0007801,18.2619695 L11,17.037 L10.8155673,16.9686082 C9.25884239,16.4128684 7.60516433,16.275144 6.00029079,16.5555343 Z M13.0000078,17.0370934 L12.9996932,18.2620176 C13.5412606,18.0864227 14.1113753,17.9855173 14.7059769,17.9605913 L15,17.9544657 C15.9920333,17.9544657 16.930557,18.078055 17.809234,18.3249848 L18,18.3814657 L18.000458,16.5557194 C16.3318708,16.2641263 14.6104659,16.4245843 13.0000078,17.0370934 Z M4.18153178,5.0890821 L4,5.186 L4,15.005 L4.06303505,14.9835348 C6.30012607,14.2545182 8.70788476,14.2256848 11.0006279,14.9264631 L11,5.055 L10.8189038,4.96641438 C8.68672067,3.96697424 6.27082403,4.01779536 4.18153178,5.0890821 Z M13.2485233,4.93284704 L13,5.053 L13.000418,14.9237981 C15.2176022,14.24164 17.5523857,14.2497554 19.7373743,14.9197474 L20,15.004 L20,5.186 L19.7983923,5.07863244 C17.7203573,4.02022621 15.3279042,3.97173828 13.2485233,4.93284704 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Calendar/Calendar-Add-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Calendar/Calendar-Add-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M8,2 L8,4 L16,4 L16,2 L18,2 L18,4 L22,4 L22,22 L2,22 L2,4 L6,4 L6,2 L8,2 Z M20,6 L4,6 L4,20 L12,20 L12,12 L20,12 L20,6 Z M18,14 L16,14 L16,16 L14,16 L14,18 L16,18 L16,20 L18,20 L18,18 L20,18 L20,16 L18,15.999 L18,14 Z M10,16 L10,18 L6,18 L6,16 L10,16 Z M10,12 L10,14 L6,14 L6,12 L10,12 Z M18,8 L18,10 L8,10 L8,8 L18,8 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 761 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Chat-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Chat-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M22,2 L22,18 L20,18 L20,22 L12,18 L2,18 L2,2 L22,2 Z M20,4 L4,4 L4,16 L12.472136,16 L18,18.764 L18,16 L20,16 L20,4 Z M14,11 L14,13 L6,13 L6,11 L14,11 Z M18,7 L18,9 L6,9 L6,7 L18,7 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Check/Check-Isolation-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Check/Check-Isolation-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<polygon id="Primary" fill="#000000" points="7.09090909 20 2 15.0222222 3.45454545 13.6 7.09090909 17.1555556 20.5454545 4 22 5.42222222"></polygon>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Copy-Item-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Copy-Item-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M12,2 L13.333,4 L20,4 L20,22 L8,22 L8,19 L4,19 L4,2 L12,2 Z M18,6 L14.667,6 L16,8 L16,19 L10,19 L10,20 L18,20 L18,6 Z M9,4 L6,4 L6,17 L14,17 L14,9 L9,9 L9,4 Z M12,12 L12,14 L8,14 L8,12 L12,12 Z M11,4.106 L11,7 L12.929,7 L11,4.106 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Edit-Content-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Edit-Content-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M14,2 L19,14 L15.5,19 L16.2,22 L7.8,22 L8.5,19 L5,14 L10,2 L14,2 Z M11,4.799 L7.269,13.754 L10.65062,18.5847269 L10.32,20 L13.679,20 L13.34938,18.5847269 L16.73,13.754 L13,4.802 L13.0010775,11.2681881 C13.5577563,11.5906726 13.9445763,12.1738636 13.9945143,12.8507377 L14,13 C14,14.1045695 13.1045695,15 12,15 C10.8954305,15 10,14.1045695 10,13 C10,12.2597476 10.4021661,11.6134261 10.9999275,11.2676063 L11,4.799 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 839 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Filter/Filter-Outline-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Filter/Filter-Outline-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M22,2 L22,6 L15,12 L15,18 L9,22 L9,12 L2,6 L2,2 L22,2 Z M20,4 L4,4 L4,5.08 L11,11.0801302 L11,18.262 L13,16.928 L13,11.0801302 L20,5.079 L20,4 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 586 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Folder-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Folder-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M7,5 L10.0003125,7 L18,7 L17.9992499,11 L22,11 L18,19 L3.78624992,19 L2.00024992,17.213 L2.00024992,5 L7,5 Z M18.764,13 L7.235,13 L5.235,17 L16.764,17 L18.764,13 Z M6.394,7 L4,7 L3.99924992,15.001 L6,11 L15.9992499,11 L16,9 L9.39481368,9 L6.394,7 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 660 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Globe-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Globe-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M12,2 C12.1003419,2 12.2003387,2.00147789 12.2999697,2.00441308 L12,2 C12.1537617,2 12.3067129,2.00347034 12.4587794,2.01033697 C12.4954521,2.01199421 12.5317231,2.01382935 12.5679426,2.01585723 C12.6264737,2.01913275 12.6851372,2.02293127 12.7436608,2.02723346 C12.7915316,2.03075478 12.8388385,2.03457179 12.8860514,2.03871689 C12.8982466,2.03978664 12.9111814,2.04094656 12.9241091,2.04213109 C18.0147981,2.50960486 22,6.78940554 22,12 C22,17.2105945 18.0147981,21.4903951 12.926111,21.9576853 L12.9241091,21.9578689 L12.8860514,21.9612831 C12.8388385,21.9654282 12.7915316,21.9692452 12.7441328,21.9727318 L12.9241091,21.9578689 C12.8058809,21.9687019 12.6870573,21.9774769 12.5676745,21.9841578 C12.5317231,21.9861707 12.4954521,21.9880058 12.4591307,21.9896472 C12.4057799,21.9920562 12.3526729,21.9940369 12.2994616,21.9956019 C12.2003387,21.9985221 12.1003419,22 12,22 C11.8996581,22 11.7996613,21.9985221 11.7000303,21.9955869 L12,22 C11.8492858,22 11.6993504,21.9966659 11.5502634,21.9900673 C11.5101985,21.9882925 11.4705955,21.9863044 11.431054,21.9840865 C11.3753284,21.9809613 11.3196693,21.9773757 11.2641361,21.9733367 C11.2121078,21.9695511 11.1604671,21.965392 11.1089387,21.960842 C11.0987512,21.9599432 11.087819,21.9589607 11.0768919,21.9579606 C5.98572453,21.4909563 2,17.2109361 2,12 C2,6.79145534 5.98206706,2.51297228 11.067884,2.04286798 L11.0698856,2.04268317 L11.1289827,2.03740833 C11.1844726,2.03262043 11.2400921,2.0282857 11.2958375,2.02440786 L11.0698856,2.04268317 C11.1795419,2.03256928 11.289711,2.02422582 11.4003642,2.01768164 C11.4684108,2.01365454 11.5370454,2.01029212 11.6058584,2.00762503 C11.6379601,2.00638212 11.6696885,2.00530261 11.7014541,2.00437123 C11.8003324,2.00146798 11.8999948,2 12,2 Z M14.9605708,13.0011053 L9.03942993,13.0011053 C9.2556374,15.7345351 10.3482037,18.1858104 12.0002978,20 C13.6518605,18.1858104 14.7443695,15.7345351 14.9605708,13.0011053 Z M7.03411742,13.0007857 L4.06201291,13.0009551 C4.43072038,15.9548613 6.40987685,18.408508 9.09305822,19.4554712 C7.91128979,17.5578463 7.19398687,15.3401075 7.03411742,13.0007857 Z M19.9379871,13.0009551 L16.9658883,13.000706 C16.8060361,15.3400045 16.0887799,17.5578096 14.9073876,19.4559914 C17.5901232,18.408508 19.5692796,15.9548613 19.9379871,13.0009551 Z M9.09228723,4.54397469 L8.94031619,4.60595876 C6.33467096,5.68538778 4.4232472,8.10203203 4.06188768,11.0000487 L7.03399151,10.9998221 C7.19349016,8.66134944 7.90999033,6.44350106 9.09228723,4.54397469 Z M12.0010044,4.00000006 L11.999,4 L11.8375134,4.1817898 L11.6215735,4.43792576 C10.1816087,6.20031489 9.23813979,8.48027149 9.03926591,10.9998612 L14.9607421,10.999963 C14.7450258,8.26670869 13.6530751,5.81544737 12.0016078,4.00098383 L12.0010044,4.00000006 Z M14.9069418,4.54452879 L14.9759,4.65460226 C16.116896,6.52836364 16.8095759,8.70587882 16.9660312,11.0002812 L19.9381123,11.0000487 C19.569728,8.04569458 17.5904271,5.59161056 14.9069418,4.54452879 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Library-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Library-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M20,20 L20,22 L4,22 L4,20 L20,20 Z M13,12 L13,18 L11,18 L11,12 L13,12 Z M18,12 L18,18 L16,18 L16,12 L18,12 Z M8,12 L8,18 L6,18 L6,12 L8,12 Z M12,2 L22,10 L2,10 L12,2 Z M12,4.561 L7.7,8 L16.299,8 L12,4.561 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 620 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Location-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Location-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M22,2 L12,22 L9.36956522,14.6304348 L2,12 L22,2 Z M17.528,6.471 L7.087,11.692 L10.9345531,13.0654469 L12.307,16.912 L17.528,6.471 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Mag-Glass-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Mag-Glass-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M15.4059165,4.30009164 C18.2686816,7.16285678 18.4591323,11.6859853 15.9772685,14.7696678 L16.7941446,15.4059165 L22,20.6117719 L20.6117719,22 L15.4059165,16.7941446 L14.7696678,15.9772685 C11.6859853,18.4591323 7.16285678,18.2686816 4.30009164,15.4059165 C1.23330279,12.3391276 1.23330279,7.36688049 4.30009164,4.30009164 C7.36688049,1.23330279 12.3391276,1.23330279 15.4059165,4.30009164 Z M5.54949693,5.54949693 C3.17273557,7.92625829 3.17273557,11.7797498 5.54949693,14.1565112 C7.92625829,16.5332726 11.7797498,16.5332726 14.1565112,14.1565112 C16.5332726,11.7797498 16.5332726,7.92625829 14.1565112,5.54949693 C11.7797498,3.17273557 7.92625829,3.17273557 5.54949693,5.54949693 Z" id="Shape" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Mail-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Mail-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M12,2 L22,8 L22,22 L2,22 L2,8 L12,2 Z M20,11.4 L12,16.2 L4,11.4 L4,20 L20,20 L20,11.4 Z M12,8.331 L7.386,11.099 L12,13.869 L16.614,11.099 L12,8.331 Z M12,4.331 L4.052,9.099 L5.443,9.934 L12,6 L18.557,9.934 L19.948,9.099 L12,4.331 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 639 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Refresh-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Refresh-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M4,12 C4,9.49542359 5.15094447,7.25966466 6.95278465,5.79277197 L8.5273711,7.10647326 C6.99782453,8.19385943 6,9.98040251 6,12 C6,15.3137085 8.6862915,18 12,18 L12,16 L16,19 L12,22 L12,20 L12,20 C7.581722,20 4,16.418278 4,12 Z M12,2 L12,4 L12,4 C16.418278,4 20,7.581722 20,12 C20,14.5045764 18.8490555,16.7403353 17.0472153,18.207228 L15.4716163,16.8942464 C17.0017351,15.806929 18,14.0200431 18,12 C18,8.6862915 15.3137085,6 12,6 L12,8 L8,5 L12,2 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 863 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/Upload-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/Upload-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M4,16 L4,20 L20,20 L20,16 L22,16 L22,22 L2,22 L2,16 L4,16 Z M12.0002405,2 L17.054,7.544 L15.658,8.963 L13,6.047 L13,18 L11,18 L11,6.049 L8.343,8.963 L6.947,7.543 L12.0002405,2 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>🧩 Icons/Simple/Action/24px/View-More-MD</title>
|
||||||
|
<g id="🧩-Icons/Simple/Action/24px/View-More-MD" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M19,9 C20.6568542,9 22,10.3431458 22,12 C22,13.6568542 20.6568542,15 19,15 C17.3431458,15 16,13.6568542 16,12 C16,10.3431458 17.3431458,9 19,9 Z M12,9 C13.6568542,9 15,10.3431458 15,12 C15,13.6568542 13.6568542,15 12,15 C10.3431458,15 9,13.6568542 9,12 C9,10.3431458 10.3431458,9 12,9 Z M5,9 C6.65685425,9 8,10.3431458 8,12 C8,13.6568542 6.65685425,15 5,15 C3.34314575,15 2,13.6568542 2,12 C2,10.3431458 3.34314575,9 5,9 Z M19.005,10.88 C18.3836797,10.88 17.88,11.3836797 17.88,12.005 C17.88,12.6263203 18.3836797,13.13 19.005,13.13 C19.6263203,13.13 20.13,12.6263203 20.13,12.005 C20.13,11.3836797 19.6263203,10.88 19.005,10.88 Z M12.005,10.88 C11.3836797,10.88 10.88,11.3836797 10.88,12.005 C10.88,12.6263203 11.3836797,13.13 12.005,13.13 C12.6263203,13.13 13.13,12.6263203 13.13,12.005 C13.13,11.3836797 12.6263203,10.88 12.005,10.88 Z M5.005,10.88 C4.38367966,10.88 3.88,11.3836797 3.88,12.005 C3.88,12.6263203 4.38367966,13.13 5.005,13.13 C5.62632034,13.13 6.13,12.6263203 6.13,12.005 C6.13,11.3836797 5.62632034,10.88 5.005,10.88 Z" id="Primary" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,254 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Familienarchiv — Design System Tokens
|
||||||
|
Extracted from frontend/src/routes/layout.css
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── Fonts ─── */
|
||||||
|
/* Tinos = Times substitute, Montserrat = Gotham substitute
|
||||||
|
(De Gruyter Brill CI) — loaded from Google Fonts. */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ─── Font families ─── */
|
||||||
|
--font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-serif: 'Tinos', 'Times New Roman', Georgia, serif;
|
||||||
|
|
||||||
|
/* ─── Raw brand palette (never used directly in components) ─── */
|
||||||
|
--palette-navy: #012851;
|
||||||
|
--palette-mint: #a1dcd8;
|
||||||
|
--palette-turquoise: #00c7b1;
|
||||||
|
--palette-sand: #f0efe9;
|
||||||
|
|
||||||
|
/* ─── Semantic surfaces ─── */
|
||||||
|
--c-canvas: #f0efe9; /* app background (sand) */
|
||||||
|
--c-surface: #ffffff; /* cards / inputs */
|
||||||
|
--c-overlay: #ffffff; /* menus, popovers */
|
||||||
|
--c-muted: #f5f4ef; /* subtle fills */
|
||||||
|
|
||||||
|
/* ─── Borders ─── */
|
||||||
|
--c-line: #e4e2d7;
|
||||||
|
--c-line-2: #eeede8;
|
||||||
|
|
||||||
|
/* ─── Text (ink) ─── */
|
||||||
|
--c-ink: #012851; /* navy — primary text */
|
||||||
|
--c-ink-2: #4b5563; /* gray-600 — body secondary */
|
||||||
|
--c-ink-3: #6b7280; /* gray-500 — meta / placeholder */
|
||||||
|
|
||||||
|
/* ─── Accent: decorative mint ─── */
|
||||||
|
--c-accent: #a1dcd8;
|
||||||
|
--c-accent-bg: rgba(161, 220, 216, 0.15);
|
||||||
|
|
||||||
|
/* ─── Primary interactive (navy) ─── */
|
||||||
|
--c-primary: #012851;
|
||||||
|
--c-primary-fg: #ffffff;
|
||||||
|
|
||||||
|
/* ─── Header — always brand-navy ─── */
|
||||||
|
--c-header: #012851;
|
||||||
|
|
||||||
|
/* ─── Turquoise — transcription accent ─── */
|
||||||
|
--c-turquoise: #00c7b1;
|
||||||
|
--c-turquoise-fg: #ffffff;
|
||||||
|
|
||||||
|
/* ─── Focus ring (navy in light mode) ─── */
|
||||||
|
--c-focus-ring: #012851;
|
||||||
|
|
||||||
|
/* ─── Danger ─── */
|
||||||
|
--c-danger: #c0392b;
|
||||||
|
--c-danger-fg: #ffffff;
|
||||||
|
|
||||||
|
/* ─── Warning (amber AA on white) ─── */
|
||||||
|
--c-warning: #b45309;
|
||||||
|
--c-warning-fg: #ffffff;
|
||||||
|
|
||||||
|
/* ─── PDF viewer chrome ─── */
|
||||||
|
--c-pdf-bg: #ebebeb;
|
||||||
|
--c-pdf-ctrl: #d8d8d8;
|
||||||
|
--c-pdf-text: #333333;
|
||||||
|
|
||||||
|
/* ─── Tag dot colors (decorative) ─── */
|
||||||
|
--c-tag-sage: #5a8a6a;
|
||||||
|
--c-tag-sienna: #a0522d;
|
||||||
|
--c-tag-amber: #c17a00;
|
||||||
|
--c-tag-slate: #607080;
|
||||||
|
--c-tag-violet: #7a4f9a;
|
||||||
|
--c-tag-rose: #c0446e;
|
||||||
|
--c-tag-cobalt: #3060b0;
|
||||||
|
--c-tag-moss: #4a7a3a;
|
||||||
|
--c-tag-sand: #9a8040;
|
||||||
|
--c-tag-coral: #c05540;
|
||||||
|
|
||||||
|
/* ─── Person badge types ─── */
|
||||||
|
--c-badge-institution-bg: #e8eff7;
|
||||||
|
--c-badge-institution-text: #1a4971;
|
||||||
|
--c-badge-institution-border: #c4d5e8;
|
||||||
|
--c-badge-group-bg: #f0e8f5;
|
||||||
|
--c-badge-group-text: #5a2d6f;
|
||||||
|
--c-badge-group-border: #d8c5e3;
|
||||||
|
--c-badge-unknown-bg: #fdf4e3;
|
||||||
|
--c-badge-unknown-text: #7a5a0a;
|
||||||
|
--c-badge-unknown-border: #f0ddb3;
|
||||||
|
|
||||||
|
/* ─── Spacing / radii ─── */
|
||||||
|
--radius-none: 0;
|
||||||
|
--radius-sm: 2px; /* cards, inputs — "rounded-sm" in tailwind */
|
||||||
|
--radius-md: 4px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ─── Shadows ─── */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* ─── Breakpoints (reference only) ─── */
|
||||||
|
--breakpoint-xs: 375px;
|
||||||
|
|
||||||
|
/* ─── Typographic sizes ─── */
|
||||||
|
--text-huge: 4rem; /* 64px — hero */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Dark mode (navy-tinted) ─── */
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--c-canvas: #010e1e;
|
||||||
|
--c-surface: #011526;
|
||||||
|
--c-overlay: #011e38;
|
||||||
|
--c-muted: #011a30;
|
||||||
|
|
||||||
|
--c-line: #0d3358;
|
||||||
|
--c-line-2: #092843;
|
||||||
|
|
||||||
|
--c-ink: #f0efe9;
|
||||||
|
--c-ink-2: #9ca3af;
|
||||||
|
--c-ink-3: #8b97a5;
|
||||||
|
|
||||||
|
--c-accent: #00c7b1;
|
||||||
|
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
||||||
|
|
||||||
|
--c-primary: #a1dcd8;
|
||||||
|
--c-primary-fg: #012851;
|
||||||
|
|
||||||
|
--c-header: #012851;
|
||||||
|
|
||||||
|
--c-focus-ring: #a1dcd8;
|
||||||
|
|
||||||
|
--c-pdf-bg: #010e1e;
|
||||||
|
--c-pdf-ctrl: #011526;
|
||||||
|
--c-pdf-text: #f0efe9;
|
||||||
|
|
||||||
|
--c-danger: #e55347;
|
||||||
|
--c-danger-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Base element styles ─── */
|
||||||
|
body {
|
||||||
|
background-color: var(--c-canvas);
|
||||||
|
color: var(--c-ink);
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Semantic typography
|
||||||
|
The app uses Montserrat for UI labels / headings / buttons and
|
||||||
|
Tinos for body, letter content, transcriptions, and document
|
||||||
|
titles. Labels are almost always UPPERCASE + tracking-widest. ─── */
|
||||||
|
|
||||||
|
.ds-display {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 64px; /* text-huge */
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: var(--c-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-h1 {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em; /* tracking-widest */
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--c-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-h2 {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--c-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-h3 { /* section title — serif, used for documents */
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--c-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-body {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--c-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-body-sm {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--c-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-meta { /* timestamps, counts, field meta */
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-label { /* form labels, section captions */
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--c-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-label-xs { /* tag chip style — extra-tiny caps */
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--c-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-button-label { /* buttons, nav items */
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-link {
|
||||||
|
color: var(--c-ink);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--c-accent);
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-italic-quote { /* search snippet / excerpt */
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--c-ink-2);
|
||||||
|
}
|
||||||
1464
design_handoff_familienarchiv_redesign/prototypes/support.js
vendored
Normal file
@@ -538,6 +538,29 @@ pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump
|
|||||||
|
|
||||||
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
|
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
|
||||||
|
|
||||||
|
### Deploy note — V78 (person_relationships from/to → date + precision, #837)
|
||||||
|
|
||||||
|
V78 drops `person_relationships.from_year`/`to_year` after backfilling the new
|
||||||
|
`from_date`/`to_date` + precision columns — a **one-way migration** (Flyway cannot roll
|
||||||
|
it back). Like V76 it runs its pre-check + DDL in one atomic Flyway transaction and
|
||||||
|
needs **no maintenance window** (single-writer archive, no concurrent importers).
|
||||||
|
|
||||||
|
It is, however, **not rolling-deploy-safe**: the previously-running JAR still maps the
|
||||||
|
`from_year`/`to_year` columns, so it would error against the migrated schema. Deploy in
|
||||||
|
this order (the default stop-then-start, single-instance deploy already satisfies it):
|
||||||
|
|
||||||
|
1. Take a manual `pg_dump` (see above) and confirm it completed.
|
||||||
|
2. **Stop the old JAR**, then **start the new JAR** — Flyway V78 runs first thing on the
|
||||||
|
new JAR's startup, before any request is served. Never run the old and new JARs
|
||||||
|
concurrently across this migration.
|
||||||
|
|
||||||
|
If post-deploy data issues are found, restore **only the person_relationships table**
|
||||||
|
from the pre-migration dump:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_restore -t person_relationships -d ${POSTGRES_DB} backup-YYYYMMDD.dump
|
||||||
|
```
|
||||||
|
|
||||||
### Rollback
|
### Rollback
|
||||||
|
|
||||||
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
||||||
|
|||||||
91
docs/adr/044-relationship-dates-localdate-precision.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# ADR-044 — Relationship dates become LocalDate + DatePrecision; relationships become editable
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-14
|
||||||
|
**Issue:** #837 (Zeitstrahl milestone; deferred follow-up to #773 / ADR-039)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`PersonRelationship` stored its span as `Integer fromYear`/`toYear`. A wedding could
|
||||||
|
never be more precise than `1923`, while `Person` (ADR-039), `Document`, and
|
||||||
|
`TimelineEvent` already carry full `DatePrecision`. Relationships also supported only
|
||||||
|
create + delete: fixing a wrong type, a wrong person, or adding a date learned later
|
||||||
|
meant deleting and re-creating the edge — losing `createdAt`. A `notes` column existed
|
||||||
|
that no form set and nothing displayed.
|
||||||
|
|
||||||
|
V78 replaces the two integer columns with `from_date`/`to_date` (`DATE`, nullable) plus
|
||||||
|
`from_date_precision`/`to_date_precision` (`VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'`),
|
||||||
|
backfilling existing years as `YYYY-01-01` at `YEAR` precision — exactly the V76 / ADR-039
|
||||||
|
pattern applied to the relationship edge. A new `PUT /api/persons/{id}/relationships/{relId}`
|
||||||
|
makes relationships editable, and `notes` is activated end to end.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Mirror ADR-039 verbatim for the relationship edge
|
||||||
|
|
||||||
|
`DatePrecision` is imported cross-domain from `document/` (ADR-039 §1 — value-type
|
||||||
|
sharing, not a layering breach). The precision columns are NOT NULL default `UNKNOWN`,
|
||||||
|
guarded by five named CHECK constraints (`chk_relationship_from_coherence`,
|
||||||
|
`chk_relationship_to_coherence`, `chk_relationship_date_order`,
|
||||||
|
`chk_relationship_{from,to}_precision_values`). `RelationshipService.validateRelationshipDates`
|
||||||
|
enforces the same rules first, so the user gets a structured 400
|
||||||
|
(`INVALID_DATE_PRECISION` for coherence, the new `INVALID_RELATIONSHIP_DATES` for a
|
||||||
|
`toDate < fromDate` order violation) instead of a constraint-violation 500. The form
|
||||||
|
offers only **DAY / MONTH / YEAR**; storage still accepts all seven values, and a
|
||||||
|
stored non-offered precision seeds the edit select as `YEAR` (ADR-039 §2).
|
||||||
|
|
||||||
|
### 2. Update re-runs every create invariant
|
||||||
|
|
||||||
|
An edit can violate the same invariants as a create, so `updateRelationship` re-runs
|
||||||
|
all of them: self-relation (`VALIDATION_ERROR`), date coherence + order, reverse
|
||||||
|
`PARENT_OF` (`CIRCULAR_RELATIONSHIP`), and the `(person, relatedPerson, type)` unique
|
||||||
|
constraint via `saveAndFlush` (`DUPLICATE_RELATIONSHIP`). Editing into a family type
|
||||||
|
flags both endpoints as family members (additive; never auto-unflags). The directed
|
||||||
|
orientation is preserved per viewpoint — whichever endpoint `{personId}` already holds
|
||||||
|
on the row stays put — so a `PARENT_OF` edge remains parent→child whether edited from
|
||||||
|
either person's page.
|
||||||
|
|
||||||
|
### 3. No optimistic locking (`@Version`)
|
||||||
|
|
||||||
|
`PersonRelationship` gains no `@Version`; the edit is last-write-wins, matching the
|
||||||
|
person edit form. This is a single-writer family archive, and it avoids the managed-
|
||||||
|
`setVersion` pitfall (a `setVersion` on a managed entity is silently ignored by
|
||||||
|
Hibernate — see the integration-test note in #496-era work). If concurrent curation
|
||||||
|
ever becomes real, add `@Version` plus an explicit client-version compare then.
|
||||||
|
|
||||||
|
### 4. IDOR / anti-enumeration: ownership mismatch is 404, for PUT **and** DELETE
|
||||||
|
|
||||||
|
A `{relId}` that does not belong to `{personId}` returns 404 `RELATIONSHIP_NOT_FOUND`
|
||||||
|
(a shared `loadOwnedRelationship` helper), so a curator cannot probe relationship ids
|
||||||
|
belonging to people they cannot see. This **aligns `deleteRelationship`** from its
|
||||||
|
former 403 to 404 in the same change, so the two mutating endpoints behave identically
|
||||||
|
on the same mismatch.
|
||||||
|
|
||||||
|
### 5. Derived marriage events gain precision for free
|
||||||
|
|
||||||
|
`TimelineEventService.buildMarriageEvents` now sources the Heirat date from the
|
||||||
|
`SPOUSE_OF` row's `from_date` + `from_date_precision` (previously
|
||||||
|
`LocalDate.of(fromYear, 1, 1)` at hard-coded `YEAR`). A DAY-precision wedding now
|
||||||
|
surfaces the exact day on the Zeitstrahl. `RelationshipInferenceService` is unchanged
|
||||||
|
— it is time-ignorant and never read the year fields.
|
||||||
|
|
||||||
|
### 6. `relationshipDates.ts` lives in `$lib/person/`, no new boundary
|
||||||
|
|
||||||
|
`formatRelationshipDateRange` mirrors `personLifeDates.ts` and delegates entirely to the
|
||||||
|
already-tested `formatDocumentDate` (zero new precision logic). It sits in `$lib/person/`
|
||||||
|
next to `personLifeDates.ts`; its only cross-domain import is `formatDocumentDate` from
|
||||||
|
`$lib/shared/utils/`, which the existing `person → shared` rule in `eslint.config.js`
|
||||||
|
already permits — **no new eslint boundary rule is added**.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- V78 is one-way (columns dropped) and is **not** rolling-deploy-safe — the running JAR
|
||||||
|
maps `from_year` until redeploy. Deploy order: **stop old JAR → run Flyway V78 →
|
||||||
|
start new JAR**. Rollback = targeted `pg_restore -t person_relationships` from the
|
||||||
|
pre-deploy dump (see `docs/DEPLOYMENT.md` §8). No maintenance window needed
|
||||||
|
(single-writer archive).
|
||||||
|
- Relationships are fully editable (type, related person, dates, notes) and the read
|
||||||
|
view shows the date range + notes.
|
||||||
|
- `RelationshipDTO` drops `fromYear`/`toYear` for `fromDate`/`fromDatePrecision`/
|
||||||
|
`toDate`/`toDatePrecision`; the `personBirthYear`/`relatedPersonBirthYear` derived
|
||||||
|
fields are unaffected (ADR-039 §3).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@startuml db-orm
|
@startuml db-orm
|
||||||
' Schema source: Flyway V1–V77 (excl. V37, V43 — intentionally removed)
|
' Schema source: Flyway V1–V78 (excl. V37, V43 — intentionally removed)
|
||||||
' Schema as of: V77 (2026-06-12)
|
' Schema as of: V78 (2026-06-14)
|
||||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||||
|
|
||||||
hide circle
|
hide circle
|
||||||
@@ -211,8 +211,10 @@ package "Persons" {
|
|||||||
person_id : UUID <<FK>>
|
person_id : UUID <<FK>>
|
||||||
related_person_id : UUID <<FK>>
|
related_person_id : UUID <<FK>>
|
||||||
relation_type : VARCHAR(30) NOT NULL
|
relation_type : VARCHAR(30) NOT NULL
|
||||||
from_year : INTEGER
|
from_date : DATE
|
||||||
to_year : INTEGER
|
from_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
|
||||||
|
to_date : DATE
|
||||||
|
to_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
|
||||||
notes : VARCHAR(2000)
|
notes : VARCHAR(2000)
|
||||||
created_at : TIMESTAMPTZ NOT NULL
|
created_at : TIMESTAMPTZ NOT NULL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
|
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
|
||||||
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
||||||
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
|
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
|
||||||
|
' Note: V78 swaps person_relationships.from_year/to_year for from_date/to_date +
|
||||||
|
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
||||||
|
|
||||||
hide circle
|
hide circle
|
||||||
skinparam linetype ortho
|
skinparam linetype ortho
|
||||||
|
|||||||
93
frontend/e2e/zeitstrahl-filter.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global /zeitstrahl layer filter (#780). Runs against the real stack with the
|
||||||
|
* seeded admin session (auth.setup). Covers the primary journey (hide the
|
||||||
|
* Letters layer → letter cards vanish + the trigger reports one active filter →
|
||||||
|
* reset restores everything) and a 375px axe pass with the collapsible open in
|
||||||
|
* both light and dark mode.
|
||||||
|
*
|
||||||
|
* #779 (the /zeitstrahl route) is merged, so this spec is NOT skipped. Per
|
||||||
|
* e2e/CLAUDE.md, E2E is not yet wired into CI — this axe gate runs locally only
|
||||||
|
* for now.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
|
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
|
||||||
|
const res = await request.post('/api/persons', {
|
||||||
|
data: { personType: 'PERSON', firstName, lastName }
|
||||||
|
});
|
||||||
|
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
|
||||||
|
return (await res.json()).id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seeds one dated letter so the timeline has content (and a LetterCard to hide). */
|
||||||
|
async function seedDatedLetter(request: APIRequestContext, isoDate: string, title: string) {
|
||||||
|
const senderId = await createPerson(request, 'Filter-Test', `Absender ${stamp()}`);
|
||||||
|
const receiverId = await createPerson(request, 'Filter-Test', `Empfaenger ${stamp()}`);
|
||||||
|
|
||||||
|
const createRes = await request.post('/api/documents', { multipart: { title } });
|
||||||
|
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
|
||||||
|
const docId = (await createRes.json()).id as string;
|
||||||
|
|
||||||
|
const put = await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title,
|
||||||
|
documentDate: isoDate,
|
||||||
|
metaDatePrecision: 'DAY',
|
||||||
|
senderId,
|
||||||
|
receiverIds: receiverId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Zeitstrahl — layer filter (#780)', () => {
|
||||||
|
test('hiding the Letters layer removes letter cards and reports the active count; reset restores', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
// A sparse year keeps the seeded letter an individual card (not a dense strip).
|
||||||
|
const title = `E2E Filter Brief ${stamp()}`;
|
||||||
|
await seedDatedLetter(request, '1903-03-03', title);
|
||||||
|
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await expect(page.getByText(title)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
await page.getByTestId('timeline-filter-letters').click();
|
||||||
|
|
||||||
|
await expect(page.getByText(title)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('timeline-filter-trigger')).toContainText('1 aktiv');
|
||||||
|
|
||||||
|
await page.getByTestId('timeline-filter-reset').click();
|
||||||
|
await expect(page.getByText(title)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no wcag2a/wcag2aa violations at 375px with the filter bar open (light + dark)', async ({
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
}) => {
|
||||||
|
await seedDatedLetter(request, '1915-06-15', `E2E Filter A11y ${stamp()}`);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 375, height: 800 });
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Open the collapsible so axe scans the toggles, not just the trigger.
|
||||||
|
await page.getByTestId('timeline-filter-trigger').click();
|
||||||
|
await expect(page.getByTestId('timeline-filter-personal')).toBeVisible();
|
||||||
|
|
||||||
|
const scan = () => new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||||
|
|
||||||
|
const light = await scan();
|
||||||
|
expect(light.violations, JSON.stringify(light.violations, null, 2)).toEqual([]);
|
||||||
|
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||||
|
const dark = await scan();
|
||||||
|
expect(dark.violations, JSON.stringify(dark.violations, null, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
frontend/e2e/zeitstrahl-note.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curator-note display on /zeitstrahl (#844) — validates that a description saved
|
||||||
|
* against a curated timeline event surfaces in the DOM under that event's title.
|
||||||
|
* Covers REQ-001 (description flows from backend) and REQ-004 (rendered below title).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
|
async function createEvent(
|
||||||
|
request: APIRequestContext,
|
||||||
|
type: 'PERSONAL' | 'HISTORICAL',
|
||||||
|
title: string,
|
||||||
|
description: string
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await request.post('/api/timeline/events', {
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
eventDate: '1918-11-11',
|
||||||
|
precision: 'DAY',
|
||||||
|
description
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!res.ok()) throw new Error(`create event failed: ${res.status()} ${await res.text()}`);
|
||||||
|
return (await res.json()).id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEvent(request: APIRequestContext, id: string): Promise<void> {
|
||||||
|
await request.delete(`/api/timeline/events/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Zeitstrahl event note (#844)', () => {
|
||||||
|
let personalEventId: string;
|
||||||
|
let historicalEventId: string;
|
||||||
|
const personalTitle = `E2E Persönlich ${stamp()}`;
|
||||||
|
const historicalTitle = `E2E Historisch ${stamp()}`;
|
||||||
|
const personalNote = 'Persönliche Notiz für diesen Moment.';
|
||||||
|
const historicalNote = 'Historischer Kontext für dieses Ereignis.';
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
personalEventId = await createEvent(request, 'PERSONAL', personalTitle, personalNote);
|
||||||
|
historicalEventId = await createEvent(request, 'HISTORICAL', historicalTitle, historicalNote);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (personalEventId) await deleteEvent(request, personalEventId);
|
||||||
|
if (historicalEventId) await deleteEvent(request, historicalEventId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PERSONAL curated event note appears below its title on /zeitstrahl (REQ-001, REQ-004)', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
|
||||||
|
const personalEntry = page.locator('li').filter({ hasText: personalTitle }).first();
|
||||||
|
await expect(personalEntry).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const note = personalEntry.locator('[data-testid="event-note"]');
|
||||||
|
await expect(note).toBeVisible();
|
||||||
|
await expect(note).toContainText(personalNote);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HISTORICAL curated event note appears below its title on /zeitstrahl (REQ-001, REQ-004)', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto('/zeitstrahl');
|
||||||
|
|
||||||
|
const historicalEntry = page.locator('li').filter({ hasText: historicalTitle }).first();
|
||||||
|
await expect(historicalEntry).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const note = historicalEntry.locator('[data-testid="event-note"]');
|
||||||
|
await expect(note).toBeVisible();
|
||||||
|
await expect(note).toContainText(historicalNote);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -188,10 +188,13 @@
|
|||||||
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
|
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
|
||||||
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
||||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||||
|
"person_add_event": "Ereignis für diese Person",
|
||||||
"person_docs_heading": "Gesendete Dokumente",
|
"person_docs_heading": "Gesendete Dokumente",
|
||||||
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
||||||
"person_received_docs_heading": "Empfangene Dokumente",
|
"person_received_docs_heading": "Empfangene Dokumente",
|
||||||
"person_no_received_docs": "Diese Person ist noch nicht als Empfänger verknüpft.",
|
"person_no_received_docs": "Diese Person ist noch nicht als Empfänger verknüpft.",
|
||||||
|
"person_meta_doc_count": "{count} Dokumente",
|
||||||
|
"person_meta_rel_count": "{count} Beziehungen",
|
||||||
"person_role_sender": "Gesendet",
|
"person_role_sender": "Gesendet",
|
||||||
"person_role_receiver": "Empfangen",
|
"person_role_receiver": "Empfangen",
|
||||||
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
||||||
@@ -651,6 +654,7 @@
|
|||||||
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
|
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
|
||||||
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
|
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
|
||||||
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
|
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
|
||||||
|
"error_invalid_relationship_dates": "Das Ende-Datum darf nicht vor dem Beginn-Datum liegen.",
|
||||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
@@ -1034,6 +1038,7 @@
|
|||||||
"nav_geschichten": "Geschichten",
|
"nav_geschichten": "Geschichten",
|
||||||
"nav_zeitstrahl": "Zeitstrahl",
|
"nav_zeitstrahl": "Zeitstrahl",
|
||||||
"timeline_heading": "Zeitstrahl",
|
"timeline_heading": "Zeitstrahl",
|
||||||
|
"timeline_add_event": "Ereignis hinzufügen",
|
||||||
"timeline_empty_state": "Noch keine Ereignisse.",
|
"timeline_empty_state": "Noch keine Ereignisse.",
|
||||||
"timeline_undated_section": "Ohne Datum",
|
"timeline_undated_section": "Ohne Datum",
|
||||||
"timeline_unknown_person": "Unbekannt",
|
"timeline_unknown_person": "Unbekannt",
|
||||||
@@ -1046,16 +1051,28 @@
|
|||||||
"timeline_derived_birth": "Geburt",
|
"timeline_derived_birth": "Geburt",
|
||||||
"timeline_derived_death": "Tod",
|
"timeline_derived_death": "Tod",
|
||||||
"timeline_derived_marriage": "Heirat",
|
"timeline_derived_marriage": "Heirat",
|
||||||
"timeline_grouping_date": "Gruppierung: Datum",
|
|
||||||
"timeline_provenance_derived": "abgeleitet",
|
"timeline_provenance_derived": "abgeleitet",
|
||||||
"timeline_provenance_curated": "kuratiert",
|
"timeline_provenance_curated": "kuratiert",
|
||||||
|
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
|
||||||
|
"timeline_bucket_show_less": "Weniger anzeigen",
|
||||||
"timeline_letter_glyph_label": "Brief",
|
"timeline_letter_glyph_label": "Brief",
|
||||||
|
"timeline_cluster_letter_count": "{count} Briefe",
|
||||||
"timeline_tag_chip_label": "Thema",
|
"timeline_tag_chip_label": "Thema",
|
||||||
"timeline_layer_historical_suffix": "historisch",
|
"timeline_layer_historical_suffix": "historisch",
|
||||||
"timeline_strip_density_caption": "Monats-Dichte",
|
"timeline_strip_density_caption": "Monats-Dichte",
|
||||||
"timeline_events_count": "{count} Ereignisse",
|
"timeline_events_count": "{count} Ereignisse",
|
||||||
"timeline_letters_count_singular": "1 Brief",
|
"timeline_letters_count_singular": "1 Brief",
|
||||||
"timeline_events_count_singular": "1 Ereignis",
|
"timeline_events_count_singular": "1 Ereignis",
|
||||||
|
"timeline_filter_label_layers": "Ebenen anzeigen",
|
||||||
|
"timeline_filter_layer_personal": "Persönliche Ereignisse",
|
||||||
|
"timeline_filter_layer_historical": "Historische Ereignisse",
|
||||||
|
"timeline_filter_layer_letters": "Briefe",
|
||||||
|
"timeline_filter_trigger": "Filter",
|
||||||
|
"timeline_filter_trigger_active": "Filter ({count} aktiv)",
|
||||||
|
"timeline_filter_reset": "Filter zurücksetzen",
|
||||||
|
"timeline_filter_empty_state": "Keine Einträge entsprechen diesen Filtern.",
|
||||||
|
"timeline_note_show_more": "mehr anzeigen",
|
||||||
|
"timeline_note_show_less": "weniger anzeigen",
|
||||||
"event_editor_new_title": "Neues Ereignis",
|
"event_editor_new_title": "Neues Ereignis",
|
||||||
"event_editor_edit_title": "Ereignis bearbeiten",
|
"event_editor_edit_title": "Ereignis bearbeiten",
|
||||||
"event_editor_section_when": "Wann",
|
"event_editor_section_when": "Wann",
|
||||||
@@ -1221,6 +1238,16 @@
|
|||||||
"relation_form_field_from_year": "Von Jahr",
|
"relation_form_field_from_year": "Von Jahr",
|
||||||
"relation_form_field_to_year": "Bis Jahr",
|
"relation_form_field_to_year": "Bis Jahr",
|
||||||
"relation_form_year_placeholder": "z.B. 1920",
|
"relation_form_year_placeholder": "z.B. 1920",
|
||||||
|
"relation_label_from_date": "Beginn (Datum)",
|
||||||
|
"relation_label_to_date": "Ende (Datum)",
|
||||||
|
"relation_label_date_precision": "Genauigkeit",
|
||||||
|
"relation_precision_day": "Genaues Datum (Tag)",
|
||||||
|
"relation_precision_month": "Monat bekannt",
|
||||||
|
"relation_precision_year": "Nur Jahreszahl",
|
||||||
|
"relation_label_notes": "Notizen",
|
||||||
|
"relation_notes_placeholder": "Optionaler Hinweis zu dieser Beziehung",
|
||||||
|
"relation_date_placeholder_hint": "Leer lassen, wenn unbekannt",
|
||||||
|
"relation_edit": "Beziehung bearbeiten",
|
||||||
"person_relationships_heading": "Beziehungen",
|
"person_relationships_heading": "Beziehungen",
|
||||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||||
|
|||||||
@@ -188,10 +188,13 @@
|
|||||||
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
|
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
|
||||||
"person_year_error": "Please enter a four-digit year",
|
"person_year_error": "Please enter a four-digit year",
|
||||||
"person_years_error_order": "Birth year must be before death year",
|
"person_years_error_order": "Birth year must be before death year",
|
||||||
|
"person_add_event": "Add event for this person",
|
||||||
"person_docs_heading": "Sent documents",
|
"person_docs_heading": "Sent documents",
|
||||||
"person_no_docs": "This person has not yet been linked as a sender.",
|
"person_no_docs": "This person has not yet been linked as a sender.",
|
||||||
"person_received_docs_heading": "Received documents",
|
"person_received_docs_heading": "Received documents",
|
||||||
"person_no_received_docs": "This person has not yet been linked as a receiver.",
|
"person_no_received_docs": "This person has not yet been linked as a receiver.",
|
||||||
|
"person_meta_doc_count": "{count} documents",
|
||||||
|
"person_meta_rel_count": "{count} relationships",
|
||||||
"person_role_sender": "Sent",
|
"person_role_sender": "Sent",
|
||||||
"person_role_receiver": "Received",
|
"person_role_receiver": "Received",
|
||||||
"person_co_correspondents_heading": "Frequent correspondents",
|
"person_co_correspondents_heading": "Frequent correspondents",
|
||||||
@@ -651,6 +654,7 @@
|
|||||||
"error_invalid_date_range": "The end date must not be before the start date.",
|
"error_invalid_date_range": "The end date must not be before the start date.",
|
||||||
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
|
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
|
||||||
"error_invalid_date_precision": "Date and precision do not match.",
|
"error_invalid_date_precision": "Date and precision do not match.",
|
||||||
|
"error_invalid_relationship_dates": "The end date must not be before the start date.",
|
||||||
"validation_last_name_required": "Last name is required.",
|
"validation_last_name_required": "Last name is required.",
|
||||||
"validation_first_name_required": "First name is required.",
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
@@ -1034,6 +1038,7 @@
|
|||||||
"nav_geschichten": "Stories",
|
"nav_geschichten": "Stories",
|
||||||
"nav_zeitstrahl": "Timeline",
|
"nav_zeitstrahl": "Timeline",
|
||||||
"timeline_heading": "Timeline",
|
"timeline_heading": "Timeline",
|
||||||
|
"timeline_add_event": "Add event",
|
||||||
"timeline_empty_state": "No events yet.",
|
"timeline_empty_state": "No events yet.",
|
||||||
"timeline_undated_section": "Without Date",
|
"timeline_undated_section": "Without Date",
|
||||||
"timeline_unknown_person": "Unknown",
|
"timeline_unknown_person": "Unknown",
|
||||||
@@ -1046,16 +1051,28 @@
|
|||||||
"timeline_derived_birth": "Birth",
|
"timeline_derived_birth": "Birth",
|
||||||
"timeline_derived_death": "Death",
|
"timeline_derived_death": "Death",
|
||||||
"timeline_derived_marriage": "Marriage",
|
"timeline_derived_marriage": "Marriage",
|
||||||
"timeline_grouping_date": "Grouping: Date",
|
|
||||||
"timeline_provenance_derived": "derived",
|
"timeline_provenance_derived": "derived",
|
||||||
"timeline_provenance_curated": "curated",
|
"timeline_provenance_curated": "curated",
|
||||||
|
"timeline_bucket_show_more": "+ {count} more letters",
|
||||||
|
"timeline_bucket_show_less": "Show fewer",
|
||||||
"timeline_letter_glyph_label": "Letter",
|
"timeline_letter_glyph_label": "Letter",
|
||||||
|
"timeline_cluster_letter_count": "{count} letters",
|
||||||
"timeline_tag_chip_label": "Topic",
|
"timeline_tag_chip_label": "Topic",
|
||||||
"timeline_layer_historical_suffix": "historical",
|
"timeline_layer_historical_suffix": "historical",
|
||||||
"timeline_strip_density_caption": "Monthly density",
|
"timeline_strip_density_caption": "Monthly density",
|
||||||
"timeline_events_count": "{count} events",
|
"timeline_events_count": "{count} events",
|
||||||
"timeline_letters_count_singular": "1 letter",
|
"timeline_letters_count_singular": "1 letter",
|
||||||
"timeline_events_count_singular": "1 event",
|
"timeline_events_count_singular": "1 event",
|
||||||
|
"timeline_filter_label_layers": "Show layers",
|
||||||
|
"timeline_filter_layer_personal": "Personal events",
|
||||||
|
"timeline_filter_layer_historical": "Historical events",
|
||||||
|
"timeline_filter_layer_letters": "Letters",
|
||||||
|
"timeline_filter_trigger": "Filter",
|
||||||
|
"timeline_filter_trigger_active": "Filter ({count} active)",
|
||||||
|
"timeline_filter_reset": "Reset filters",
|
||||||
|
"timeline_filter_empty_state": "No entries match these filters.",
|
||||||
|
"timeline_note_show_more": "show more",
|
||||||
|
"timeline_note_show_less": "show less",
|
||||||
"event_editor_new_title": "New event",
|
"event_editor_new_title": "New event",
|
||||||
"event_editor_edit_title": "Edit event",
|
"event_editor_edit_title": "Edit event",
|
||||||
"event_editor_section_when": "When",
|
"event_editor_section_when": "When",
|
||||||
@@ -1221,6 +1238,16 @@
|
|||||||
"relation_form_field_from_year": "From year",
|
"relation_form_field_from_year": "From year",
|
||||||
"relation_form_field_to_year": "To year",
|
"relation_form_field_to_year": "To year",
|
||||||
"relation_form_year_placeholder": "e.g. 1920",
|
"relation_form_year_placeholder": "e.g. 1920",
|
||||||
|
"relation_label_from_date": "Start date",
|
||||||
|
"relation_label_to_date": "End date",
|
||||||
|
"relation_label_date_precision": "Precision",
|
||||||
|
"relation_precision_day": "Exact date (day)",
|
||||||
|
"relation_precision_month": "Month known",
|
||||||
|
"relation_precision_year": "Year only",
|
||||||
|
"relation_label_notes": "Notes",
|
||||||
|
"relation_notes_placeholder": "Optional note about this relationship",
|
||||||
|
"relation_date_placeholder_hint": "Leave empty if unknown",
|
||||||
|
"relation_edit": "Edit relationship",
|
||||||
"person_relationships_heading": "Relationships",
|
"person_relationships_heading": "Relationships",
|
||||||
"person_relationships_empty": "No relationships known yet.",
|
"person_relationships_empty": "No relationships known yet.",
|
||||||
"timeline_aria_label": "Document density timeline",
|
"timeline_aria_label": "Document density timeline",
|
||||||
|
|||||||
@@ -188,10 +188,13 @@
|
|||||||
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
|
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
|
||||||
"person_year_error": "Introduzca un año de cuatro dígitos",
|
"person_year_error": "Introduzca un año de cuatro dígitos",
|
||||||
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
||||||
|
"person_add_event": "Añadir evento para esta persona",
|
||||||
"person_docs_heading": "Documentos enviados",
|
"person_docs_heading": "Documentos enviados",
|
||||||
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
||||||
"person_received_docs_heading": "Documentos recibidos",
|
"person_received_docs_heading": "Documentos recibidos",
|
||||||
"person_no_received_docs": "Esta persona aún no está vinculada como receptor.",
|
"person_no_received_docs": "Esta persona aún no está vinculada como receptor.",
|
||||||
|
"person_meta_doc_count": "{count} documentos",
|
||||||
|
"person_meta_rel_count": "{count} relaciones",
|
||||||
"person_role_sender": "Enviado",
|
"person_role_sender": "Enviado",
|
||||||
"person_role_receiver": "Recibido",
|
"person_role_receiver": "Recibido",
|
||||||
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||||
@@ -651,6 +654,7 @@
|
|||||||
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
|
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
|
||||||
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
|
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
|
||||||
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
|
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
|
||||||
|
"error_invalid_relationship_dates": "La fecha de fin no puede ser anterior a la de inicio.",
|
||||||
"validation_last_name_required": "El apellido es obligatorio.",
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
"validation_first_name_required": "El nombre es obligatorio.",
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
@@ -1034,6 +1038,7 @@
|
|||||||
"nav_geschichten": "Historias",
|
"nav_geschichten": "Historias",
|
||||||
"nav_zeitstrahl": "Línea de tiempo",
|
"nav_zeitstrahl": "Línea de tiempo",
|
||||||
"timeline_heading": "Línea de tiempo",
|
"timeline_heading": "Línea de tiempo",
|
||||||
|
"timeline_add_event": "Añadir evento",
|
||||||
"timeline_empty_state": "Aún no hay eventos.",
|
"timeline_empty_state": "Aún no hay eventos.",
|
||||||
"timeline_undated_section": "Sin Fecha",
|
"timeline_undated_section": "Sin Fecha",
|
||||||
"timeline_unknown_person": "Desconocido",
|
"timeline_unknown_person": "Desconocido",
|
||||||
@@ -1046,16 +1051,28 @@
|
|||||||
"timeline_derived_birth": "Nacimiento",
|
"timeline_derived_birth": "Nacimiento",
|
||||||
"timeline_derived_death": "Fallecimiento",
|
"timeline_derived_death": "Fallecimiento",
|
||||||
"timeline_derived_marriage": "Matrimonio",
|
"timeline_derived_marriage": "Matrimonio",
|
||||||
"timeline_grouping_date": "Agrupación: Fecha",
|
|
||||||
"timeline_provenance_derived": "derivado",
|
"timeline_provenance_derived": "derivado",
|
||||||
"timeline_provenance_curated": "curado",
|
"timeline_provenance_curated": "curado",
|
||||||
|
"timeline_bucket_show_more": "+ {count} cartas más",
|
||||||
|
"timeline_bucket_show_less": "Mostrar menos",
|
||||||
"timeline_letter_glyph_label": "Carta",
|
"timeline_letter_glyph_label": "Carta",
|
||||||
|
"timeline_cluster_letter_count": "{count} cartas",
|
||||||
"timeline_tag_chip_label": "Tema",
|
"timeline_tag_chip_label": "Tema",
|
||||||
"timeline_layer_historical_suffix": "histórico",
|
"timeline_layer_historical_suffix": "histórico",
|
||||||
"timeline_strip_density_caption": "Densidad mensual",
|
"timeline_strip_density_caption": "Densidad mensual",
|
||||||
"timeline_events_count": "{count} eventos",
|
"timeline_events_count": "{count} eventos",
|
||||||
"timeline_letters_count_singular": "1 carta",
|
"timeline_letters_count_singular": "1 carta",
|
||||||
"timeline_events_count_singular": "1 evento",
|
"timeline_events_count_singular": "1 evento",
|
||||||
|
"timeline_filter_label_layers": "Mostrar capas",
|
||||||
|
"timeline_filter_layer_personal": "Eventos personales",
|
||||||
|
"timeline_filter_layer_historical": "Eventos históricos",
|
||||||
|
"timeline_filter_layer_letters": "Cartas",
|
||||||
|
"timeline_filter_trigger": "Filtro",
|
||||||
|
"timeline_filter_trigger_active": "Filtro ({count} activos)",
|
||||||
|
"timeline_filter_reset": "Restablecer filtros",
|
||||||
|
"timeline_filter_empty_state": "Ninguna entrada coincide con estos filtros.",
|
||||||
|
"timeline_note_show_more": "mostrar más",
|
||||||
|
"timeline_note_show_less": "mostrar menos",
|
||||||
"event_editor_new_title": "Nuevo evento",
|
"event_editor_new_title": "Nuevo evento",
|
||||||
"event_editor_edit_title": "Editar evento",
|
"event_editor_edit_title": "Editar evento",
|
||||||
"event_editor_section_when": "Cuándo",
|
"event_editor_section_when": "Cuándo",
|
||||||
@@ -1221,6 +1238,16 @@
|
|||||||
"relation_form_field_from_year": "Desde año",
|
"relation_form_field_from_year": "Desde año",
|
||||||
"relation_form_field_to_year": "Hasta año",
|
"relation_form_field_to_year": "Hasta año",
|
||||||
"relation_form_year_placeholder": "ej. 1920",
|
"relation_form_year_placeholder": "ej. 1920",
|
||||||
|
"relation_label_from_date": "Fecha de inicio",
|
||||||
|
"relation_label_to_date": "Fecha de fin",
|
||||||
|
"relation_label_date_precision": "Precisión",
|
||||||
|
"relation_precision_day": "Fecha exacta (día)",
|
||||||
|
"relation_precision_month": "Mes conocido",
|
||||||
|
"relation_precision_year": "Solo año",
|
||||||
|
"relation_label_notes": "Notas",
|
||||||
|
"relation_notes_placeholder": "Nota opcional sobre esta relación",
|
||||||
|
"relation_date_placeholder_hint": "Dejar vacío si es desconocido",
|
||||||
|
"relation_edit": "Editar relación",
|
||||||
"person_relationships_heading": "Relaciones",
|
"person_relationships_heading": "Relaciones",
|
||||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||||
|
|||||||
6
frontend/package-lock.json
generated
@@ -9515,9 +9515,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.3",
|
"version": "7.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/persons/{id}/relationships/{relId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["updateRelationship"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteRelationship"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/geschichten/{id}/items/reorder": {
|
"/api/geschichten/{id}/items/reorder": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1640,22 +1656,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/persons/{id}/relationships/{relId}": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete: operations["deleteRelationship"];
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/persons/{id}/aliases/{aliasId}": {
|
"/api/persons/{id}/aliases/{aliasId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1853,6 +1853,50 @@ export interface components {
|
|||||||
provisional: boolean;
|
provisional: boolean;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
};
|
};
|
||||||
|
RelationshipUpsertRequest: {
|
||||||
|
/** Format: uuid */
|
||||||
|
relatedPersonId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||||
|
/** Format: date */
|
||||||
|
fromDate?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
fromDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
/** Format: date */
|
||||||
|
toDate?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
toDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
RelationshipDTO: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
personId: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
relatedPersonId: string;
|
||||||
|
personDisplayName: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
personBirthYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
personDeathYear?: number;
|
||||||
|
relatedPersonDisplayName: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
relatedPersonBirthYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
relatedPersonDeathYear?: number;
|
||||||
|
/** @enum {string} */
|
||||||
|
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||||
|
/** Format: date */
|
||||||
|
fromDate?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
fromDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
/** Format: date */
|
||||||
|
toDate?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
toDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
JourneyReorderDTO: {
|
JourneyReorderDTO: {
|
||||||
itemIds?: string[];
|
itemIds?: string[];
|
||||||
};
|
};
|
||||||
@@ -2008,42 +2052,6 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
targetId: string;
|
targetId: string;
|
||||||
};
|
};
|
||||||
CreateRelationshipRequest: {
|
|
||||||
/** Format: uuid */
|
|
||||||
relatedPersonId: string;
|
|
||||||
/** @enum {string} */
|
|
||||||
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
|
||||||
/** Format: int32 */
|
|
||||||
fromYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
toYear?: number;
|
|
||||||
notes?: string;
|
|
||||||
};
|
|
||||||
RelationshipDTO: {
|
|
||||||
/** Format: uuid */
|
|
||||||
id: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
personId: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
relatedPersonId: string;
|
|
||||||
personDisplayName: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
personBirthYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
personDeathYear?: number;
|
|
||||||
relatedPersonDisplayName: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
relatedPersonBirthYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
relatedPersonDeathYear?: number;
|
|
||||||
/** @enum {string} */
|
|
||||||
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
|
||||||
/** Format: int32 */
|
|
||||||
fromYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
toYear?: number;
|
|
||||||
notes?: string;
|
|
||||||
};
|
|
||||||
PersonNameAliasDTO: {
|
PersonNameAliasDTO: {
|
||||||
lastName: string;
|
lastName: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -2459,6 +2467,9 @@ export interface components {
|
|||||||
rootTagId?: string;
|
rootTagId?: string;
|
||||||
rootTagName?: string;
|
rootTagName?: string;
|
||||||
rootTagColor?: string;
|
rootTagColor?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
linkedEventId?: string;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
TimelineYearDTO: {
|
TimelineYearDTO: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -2638,11 +2649,11 @@ export interface components {
|
|||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
};
|
};
|
||||||
PageableObject: {
|
PageableObject: {
|
||||||
paged?: boolean;
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
paged?: boolean;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
offset?: number;
|
offset?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
@@ -3200,6 +3211,54 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateRelationship: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
relId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RelationshipUpsertRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["RelationshipDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteRelationship: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
relId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
reorderItems: {
|
reorderItems: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3663,7 +3722,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["CreateRelationshipRequest"];
|
"application/json": components["schemas"]["RelationshipUpsertRequest"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
@@ -5909,27 +5968,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
deleteRelationship: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
id: string;
|
|
||||||
relId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description No Content */
|
|
||||||
204: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
removeAlias: {
|
removeAlias: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -74,9 +74,10 @@ describe('message key parity', () => {
|
|||||||
// every locale so no surface ever falls back to a missing translation.
|
// every locale so no surface ever falls back to a missing translation.
|
||||||
it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => {
|
it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => {
|
||||||
const requiredKeys = [
|
const requiredKeys = [
|
||||||
'timeline_grouping_date',
|
|
||||||
'timeline_provenance_derived',
|
'timeline_provenance_derived',
|
||||||
'timeline_provenance_curated',
|
'timeline_provenance_curated',
|
||||||
|
'timeline_bucket_show_more',
|
||||||
|
'timeline_bucket_show_less',
|
||||||
'timeline_letter_glyph_label',
|
'timeline_letter_glyph_label',
|
||||||
'timeline_layer_historical_suffix',
|
'timeline_layer_historical_suffix',
|
||||||
'timeline_strip_density_caption',
|
'timeline_strip_density_caption',
|
||||||
@@ -98,4 +99,47 @@ describe('message key parity', () => {
|
|||||||
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
|
expect(en).toMatchObject({ timeline_tag_chip_label: 'Topic' });
|
||||||
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
|
expect(es).toMatchObject({ timeline_tag_chip_label: 'Tema' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #850 finding #6: the event-card letter count carries an sr-only "{count} Briefe" so the
|
||||||
|
// bare "· 2" never announces to a screen reader without context.
|
||||||
|
it('zeitstrahl cluster letter-count key is present in all locales (#850 finding #6)', () => {
|
||||||
|
expect(de).toHaveProperty('timeline_cluster_letter_count');
|
||||||
|
expect(en).toHaveProperty('timeline_cluster_letter_count');
|
||||||
|
expect(es).toHaveProperty('timeline_cluster_letter_count');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #780 REQ-010: the layer-filter strings are Paraglide keys in every locale.
|
||||||
|
// timeline_filter_trigger (0 active) and timeline_filter_trigger_active ({count},
|
||||||
|
// ≥1 active) are distinct keys so the trigger never reads "Filter (0 aktiv)".
|
||||||
|
it('zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)', () => {
|
||||||
|
const requiredKeys = [
|
||||||
|
'timeline_filter_label_layers',
|
||||||
|
'timeline_filter_layer_personal',
|
||||||
|
'timeline_filter_layer_historical',
|
||||||
|
'timeline_filter_layer_letters',
|
||||||
|
'timeline_filter_trigger',
|
||||||
|
'timeline_filter_trigger_active',
|
||||||
|
'timeline_filter_reset',
|
||||||
|
'timeline_filter_empty_state'
|
||||||
|
];
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
|
||||||
|
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
|
||||||
|
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
// the active-count key carries the established {count} placeholder
|
||||||
|
expect(de.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
expect(en.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
expect(es.timeline_filter_trigger_active).toContain('{count}');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #842: the two curator-affordance CTA labels (Zeitstrahl header + person page)
|
||||||
|
// are Paraglide keys present in every locale; the edit pencils reuse btn_edit.
|
||||||
|
it('curator-affordance CTA keys are present in all locales (#842)', () => {
|
||||||
|
for (const key of ['timeline_add_event', 'person_add_event']) {
|
||||||
|
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
|
||||||
|
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
|
||||||
|
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -193,7 +193,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-spouse',
|
relatedPersonId: 'p-spouse',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Otto Raddatz',
|
relatedPersonDisplayName: 'Otto Raddatz',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'r2',
|
id: 'r2',
|
||||||
@@ -201,7 +203,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-friend',
|
relatedPersonId: 'p-friend',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Karl Friend',
|
relatedPersonDisplayName: 'Karl Friend',
|
||||||
relationType: 'FRIEND'
|
relationType: 'FRIEND',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'r3',
|
id: 'r3',
|
||||||
@@ -209,7 +213,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-sibling',
|
relatedPersonId: 'p-sibling',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Marie Sister',
|
relatedPersonDisplayName: 'Marie Sister',
|
||||||
relationType: 'SIBLING_OF'
|
relationType: 'SIBLING_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
@@ -235,7 +241,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-aug',
|
relatedPersonId: 'p-aug',
|
||||||
personDisplayName: 'Heinrich Raddatz',
|
personDisplayName: 'Heinrich Raddatz',
|
||||||
relatedPersonDisplayName: 'Auguste Raddatz',
|
relatedPersonDisplayName: 'Auguste Raddatz',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
@@ -258,7 +266,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-friend',
|
relatedPersonId: 'p-friend',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Karl Friend',
|
relatedPersonDisplayName: 'Karl Friend',
|
||||||
relationType: 'FRIEND'
|
relationType: 'FRIEND',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import DateInput from '$lib/shared/primitives/DateInput.svelte';
|
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
|
||||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
|
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
|
||||||
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
|
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
|
||||||
const PERSON_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
|
|
||||||
{ value: 'DAY', label: m.person_precision_day },
|
|
||||||
{ value: 'MONTH', label: m.person_precision_month },
|
|
||||||
{ value: 'YEAR', label: m.person_precision_year }
|
|
||||||
];
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
legend,
|
legend,
|
||||||
@@ -26,73 +19,21 @@ let {
|
|||||||
initialPrecision?: string | null;
|
initialPrecision?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let iso = $state('');
|
const precisions: { value: DatePrecision; label: string }[] = $derived([
|
||||||
let errorMessage = $state<string | null>(null);
|
{ value: 'DAY', label: m.person_precision_day() },
|
||||||
let inputEl = $state<HTMLInputElement | undefined>();
|
{ value: 'MONTH', label: m.person_precision_month() },
|
||||||
let precision = $state<DatePrecision>('DAY');
|
{ value: 'YEAR', label: m.person_precision_year() }
|
||||||
|
]);
|
||||||
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not
|
const hint = $derived(`${m.person_precision_hint()} · ${m.person_date_placeholder_hint()}`);
|
||||||
// stomp the user's in-progress edit.
|
|
||||||
onMount(() => {
|
|
||||||
if (initialIso) {
|
|
||||||
iso = initialIso;
|
|
||||||
}
|
|
||||||
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
|
|
||||||
if (offered) {
|
|
||||||
precision = initialPrecision as DatePrecision;
|
|
||||||
} else if (initialIso) {
|
|
||||||
// Legacy APPROX/SEASON/RANGE precision is not editable here — seed YEAR so an
|
|
||||||
// untouched save does not silently claim DAY precision for the stored date.
|
|
||||||
precision = 'YEAR';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// A partial date leaves the hidden ISO empty — submitting then would silently
|
|
||||||
// clear a stored date. Block native submission until completed or fully emptied.
|
|
||||||
$effect(() => {
|
|
||||||
inputEl?.setCustomValidity(errorMessage ?? '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlCls =
|
|
||||||
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset>
|
<DateInputWithPrecision
|
||||||
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
name={name}
|
||||||
{legend}
|
legend={legend}
|
||||||
</legend>
|
precisionLabel={precisionLabel}
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
precisions={precisions}
|
||||||
<div class="flex-1">
|
hint={hint}
|
||||||
<DateInput
|
initialIso={initialIso}
|
||||||
bind:value={iso}
|
initialPrecision={initialPrecision}
|
||||||
bind:errorMessage={errorMessage}
|
selectClass="bg-surface"
|
||||||
bind:inputEl={inputEl}
|
/>
|
||||||
name={name}
|
|
||||||
id={name}
|
|
||||||
placeholder="TT.MM.JJJJ"
|
|
||||||
ariaLabel={legend}
|
|
||||||
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
|
|
||||||
class={controlCls}
|
|
||||||
/>
|
|
||||||
{#if errorMessage}
|
|
||||||
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<select
|
|
||||||
id="{name}Precision"
|
|
||||||
name="{name}Precision"
|
|
||||||
aria-label="{legend}: {precisionLabel}"
|
|
||||||
bind:value={precision}
|
|
||||||
class="{controlCls} bg-surface"
|
|
||||||
>
|
|
||||||
{#each PERSON_DATE_PRECISIONS as p (p.value)}
|
|
||||||
<option value={p.value}>{p.label()}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 font-sans text-xs text-ink-3">
|
|
||||||
{m.person_precision_hint()} · {m.person_date_placeholder_hint()}
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
|
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
|
||||||
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
|
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
|
||||||
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
||||||
|
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -29,13 +30,15 @@ let {
|
|||||||
|
|
||||||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||||
|
|
||||||
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
|
const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
|
||||||
const topDerived = $derived(inferredRelationships.slice(0, 5));
|
const topDerived = $derived(inferredRelationships.slice(0, 5));
|
||||||
|
let editingRelId = $state<string | null>(null);
|
||||||
|
|
||||||
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number {
|
function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number {
|
||||||
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
|
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
|
||||||
if (order !== 0) return order;
|
if (order !== 0) return order;
|
||||||
return (a.fromYear ?? 0) - (b.fromYear ?? 0);
|
// ISO dates sort lexicographically == chronologically; a missing date sorts first.
|
||||||
|
return (a.fromDate ?? '').localeCompare(b.fromDate ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationTypeOrder(t: RelationType | undefined): number {
|
function relationTypeOrder(t: RelationType | undefined): number {
|
||||||
@@ -53,13 +56,13 @@ function relationTypeOrder(t: RelationType | undefined): number {
|
|||||||
return order[t ?? 'OTHER'] ?? 99;
|
return order[t ?? 'OTHER'] ?? 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
function yearRange(rel: RelationshipDTO): string {
|
function dateRangeOf(rel: RelationshipDTO): string {
|
||||||
const from = rel.fromYear;
|
return formatRelationshipDateRange(
|
||||||
const to = rel.toYear;
|
rel.fromDate,
|
||||||
if (from && to) return `${from}–${to}`;
|
rel.fromDatePrecision,
|
||||||
if (from) return m.relation_year_from({ year: from });
|
rel.toDate,
|
||||||
if (to) return m.relation_year_to({ year: to });
|
rel.toDatePrecision
|
||||||
return '';
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string {
|
|||||||
<RelationshipChip
|
<RelationshipChip
|
||||||
chipLabel={chipLabel(rel, personId)}
|
chipLabel={chipLabel(rel, personId)}
|
||||||
otherName={otherName(rel, personId)}
|
otherName={otherName(rel, personId)}
|
||||||
yearRange={yearRange(rel)}
|
dateRange={dateRangeOf(rel)}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
relId={rel.id}
|
relId={rel.id}
|
||||||
|
onEdit={canWrite ? () => (editingRelId = rel.id) : undefined}
|
||||||
/>
|
/>
|
||||||
|
{#if editingRelId === rel.id}
|
||||||
|
<li>
|
||||||
|
<AddRelationshipForm
|
||||||
|
personId={personId}
|
||||||
|
relationship={rel}
|
||||||
|
onClose={() => (editingRelId = null)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -111,17 +111,21 @@ describe('StammbaumCard', () => {
|
|||||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the year range "from–to" for a relationship with both years', async () => {
|
it('renders the date range "from – to" for a relationship with both dates', async () => {
|
||||||
render(StammbaumCard, {
|
render(StammbaumCard, {
|
||||||
props: baseProps({
|
props: baseProps({
|
||||||
relationships: [
|
relationships: [
|
||||||
{
|
{
|
||||||
id: 'r-1',
|
id: 'r-1',
|
||||||
|
personId: 'p-1',
|
||||||
|
relatedPersonId: 'p-x',
|
||||||
|
personDisplayName: 'Anna',
|
||||||
|
relatedPersonDisplayName: 'Xavier',
|
||||||
relationType: 'COLLEAGUE',
|
relationType: 'COLLEAGUE',
|
||||||
fromYear: 1940,
|
fromDate: '1940-01-01',
|
||||||
toYear: 1945,
|
fromDatePrecision: 'YEAR',
|
||||||
personA: { id: 'p-1', displayName: 'Anna' },
|
toDate: '1945-01-01',
|
||||||
personB: { id: 'p-x', displayName: 'Xavier' }
|
toDatePrecision: 'YEAR'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -131,23 +135,27 @@ describe('StammbaumCard', () => {
|
|||||||
expect(document.body.textContent).toContain('1945');
|
expect(document.body.textContent).toContain('1945');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders only "fromYear" for a relationship with no end year', async () => {
|
it('renders only the start date for a relationship with no end date', async () => {
|
||||||
render(StammbaumCard, {
|
render(StammbaumCard, {
|
||||||
props: baseProps({
|
props: baseProps({
|
||||||
relationships: [
|
relationships: [
|
||||||
{
|
{
|
||||||
id: 'r-2',
|
id: 'r-2',
|
||||||
|
personId: 'p-1',
|
||||||
|
relatedPersonId: 'p-y',
|
||||||
|
personDisplayName: 'Anna',
|
||||||
|
relatedPersonDisplayName: 'Yvonne',
|
||||||
relationType: 'NEIGHBOR',
|
relationType: 'NEIGHBOR',
|
||||||
fromYear: 1935,
|
fromDate: '1935-01-01',
|
||||||
personA: { id: 'p-1', displayName: 'Anna' },
|
fromDatePrecision: 'YEAR',
|
||||||
personB: { id: 'p-y', displayName: 'Yvonne' }
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.body.textContent).toContain('1935');
|
expect(document.body.textContent).toContain('1935');
|
||||||
expect(document.body.textContent).not.toContain('1935–');
|
expect(document.body.textContent).not.toContain('1935 –');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the inferred-relationships disclosure when topDerived has items', async () => {
|
it('renders the inferred-relationships disclosure when topDerived has items', async () => {
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
y2={bCenter.y}
|
y2={bCenter.y}
|
||||||
stroke="var(--c-primary)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
stroke-dasharray={e.toDate ? '4 4' : undefined}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={(aCenter.x + bCenter.x) / 2}
|
cx={(aCenter.x + bCenter.x) / 2}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ function parentEdge(parentId: string, childId: string): RelationshipDTO {
|
|||||||
relatedPersonId: childId,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,9 @@ function endedSpouseEdge(a: string, b: string): RelationshipDTO {
|
|||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF',
|
relationType: 'SPOUSE_OF',
|
||||||
toYear: 1950
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDate: '1950-01-01',
|
||||||
|
toDatePrecision: 'YEAR'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,19 @@ async function loadFor(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddRelationship(data: RelFormData) {
|
async function handleAddRelationship(data: RelFormData) {
|
||||||
const body: Record<string, string | number> = {
|
const body: Record<string, string> = {
|
||||||
relatedPersonId: data.relatedPersonId,
|
relatedPersonId: data.relatedPersonId,
|
||||||
relationType: data.relationType
|
relationType: data.relationType
|
||||||
};
|
};
|
||||||
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
|
if (data.fromDate) {
|
||||||
if (data.toYear !== undefined) body.toYear = data.toYear;
|
body.fromDate = data.fromDate;
|
||||||
|
if (data.fromDatePrecision) body.fromDatePrecision = data.fromDatePrecision;
|
||||||
|
}
|
||||||
|
if (data.toDate) {
|
||||||
|
body.toDate = data.toDate;
|
||||||
|
if (data.toDatePrecision) body.toDatePrecision = data.toDatePrecision;
|
||||||
|
}
|
||||||
|
if (data.notes) body.notes = data.notes;
|
||||||
const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
|
const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -72,20 +72,20 @@ describe('StammbaumSidePanel', () => {
|
|||||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('year inputs inside the add form have label elements (canWrite=true)', async () => {
|
it('date inputs inside the add form have accessible labels (canWrite=true)', async () => {
|
||||||
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
|
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
|
||||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||||
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
|
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
|
||||||
/Beziehung hinzufügen/i.test(b.textContent ?? '')
|
/Beziehung hinzufügen/i.test(b.textContent ?? '')
|
||||||
);
|
);
|
||||||
addBtn!.click();
|
addBtn!.click();
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
const yearInputs = [...document.querySelectorAll('input')].filter(
|
const dateInputs = [...document.querySelectorAll('input')].filter(
|
||||||
(i) => i.inputMode === 'numeric'
|
(i) => i.inputMode === 'numeric'
|
||||||
);
|
);
|
||||||
expect(yearInputs.length).toBeGreaterThan(0);
|
expect(dateInputs.length).toBeGreaterThan(0);
|
||||||
for (const input of yearInputs) {
|
for (const input of dateInputs) {
|
||||||
expect(input.closest('label')).not.toBeNull();
|
expect(input.getAttribute('aria-label')).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte';
|
|||||||
import StammbaumTree from './StammbaumTree.svelte';
|
import StammbaumTree from './StammbaumTree.svelte';
|
||||||
import type { PanZoomState } from './panZoom';
|
import type { PanZoomState } from './panZoom';
|
||||||
import { DIMMED_OPACITY } from './layout/highlightLineage';
|
import { DIMMED_OPACITY } from './layout/highlightLineage';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
const ID_A = '00000000-0000-0000-0000-000000000001';
|
const ID_A = '00000000-0000-0000-0000-000000000001';
|
||||||
const ID_B = '00000000-0000-0000-0000-000000000002';
|
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||||
@@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: PARENT_B,
|
relatedPersonId: PARENT_B,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1a',
|
id: 'p1a',
|
||||||
@@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_1,
|
relatedPersonId: CHILD_1,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1b',
|
id: 'p1b',
|
||||||
@@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_1,
|
relatedPersonId: CHILD_1,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2a',
|
id: 'p2a',
|
||||||
@@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_2,
|
relatedPersonId: CHILD_2,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2b',
|
id: 'p2b',
|
||||||
@@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_2,
|
relatedPersonId: CHILD_2,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: PARENT_B,
|
relatedPersonId: PARENT_B,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: EUGENIE,
|
relatedPersonId: EUGENIE,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p3',
|
id: 'p3',
|
||||||
@@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p4',
|
id: 'p4',
|
||||||
@@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's2',
|
id: 's2',
|
||||||
@@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HILDE,
|
relatedPersonId: HILDE,
|
||||||
personDisplayName: 'Hans',
|
personDisplayName: 'Hans',
|
||||||
relatedPersonDisplayName: 'Hilde',
|
relatedPersonDisplayName: 'Hilde',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p5',
|
id: 'p5',
|
||||||
@@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: LILI,
|
relatedPersonId: LILI,
|
||||||
personDisplayName: 'Hans',
|
personDisplayName: 'Hans',
|
||||||
relatedPersonDisplayName: 'Lili',
|
relatedPersonDisplayName: 'Lili',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p6',
|
id: 'p6',
|
||||||
@@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: LILI,
|
relatedPersonId: LILI,
|
||||||
personDisplayName: 'Hilde',
|
personDisplayName: 'Hilde',
|
||||||
relatedPersonDisplayName: 'Lili',
|
relatedPersonDisplayName: 'Lili',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF',
|
relationType: 'SPOUSE_OF',
|
||||||
toYear: 1925
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDate: '1925-01-01',
|
||||||
|
toDatePrecision: 'YEAR'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Parent',
|
personDisplayName: 'Parent',
|
||||||
relatedPersonDisplayName: 'Child',
|
relatedPersonDisplayName: 'Child',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
|||||||
personDisplayName: string;
|
personDisplayName: string;
|
||||||
relatedPersonDisplayName: string;
|
relatedPersonDisplayName: string;
|
||||||
relationType: 'PARENT_OF' | 'SPOUSE_OF';
|
relationType: 'PARENT_OF' | 'SPOUSE_OF';
|
||||||
|
fromDatePrecision: 'UNKNOWN';
|
||||||
|
toDatePrecision: 'UNKNOWN';
|
||||||
};
|
};
|
||||||
const edge = (
|
const edge = (
|
||||||
personId: string,
|
personId: string,
|
||||||
@@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
|||||||
relatedPersonId,
|
relatedPersonId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType
|
relationType,
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
});
|
});
|
||||||
|
|
||||||
const NODES = [
|
const NODES = [
|
||||||
@@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
// year, then a deterministic id tie-break), not alphabetically — with no birth
|
// year, then a deterministic id tie-break), not alphabetically — with no birth
|
||||||
// years here Walter (id …a1) owns the run and Eugenie sits to his right. So the
|
// years here Walter (id …a1) owns the run and Eugenie sits to his right. So the
|
||||||
// deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
|
// deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
|
||||||
const FAMILY_EDGES = [
|
const FAMILY_EDGES: RelationshipDTO[] = [
|
||||||
{
|
{
|
||||||
id: 'sp',
|
id: 'sp',
|
||||||
personId: WALTER,
|
personId: WALTER,
|
||||||
relatedPersonId: EUGENIE,
|
relatedPersonId: EUGENIE,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p3',
|
id: 'p3',
|
||||||
@@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p4',
|
id: 'p4',
|
||||||
@@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
|
|||||||
relatedPersonId: childId,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
|
|||||||
relatedPersonId: b,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
|
|||||||
fromYear: number | undefined,
|
fromYear: number | undefined,
|
||||||
id = a + b
|
id = a + b
|
||||||
): RelationshipDTO {
|
): RelationshipDTO {
|
||||||
return { ...spouseEdge(a, b, id), fromYear };
|
return {
|
||||||
|
...spouseEdge(a, b, id),
|
||||||
|
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
|
it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
|
||||||
@@ -329,7 +336,7 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
|
|||||||
// fail fast instead so the maintainer either updates the test or
|
// fail fast instead so the maintainer either updates the test or
|
||||||
// splits into a year-branch / name-branch pair.
|
// splits into a year-branch / name-branch pair.
|
||||||
const spouseEdgesWithYear = fixtureEdges.filter(
|
const spouseEdgesWithYear = fixtureEdges.filter(
|
||||||
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null
|
(e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
spouseEdgesWithYear,
|
spouseEdgesWithYear,
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO {
|
|||||||
relatedPersonId: c,
|
relatedPersonId: c,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +35,9 @@ function spouse(a: string, b: string, fromYear?: number): RelationshipDTO {
|
|||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF',
|
relationType: 'SPOUSE_OF',
|
||||||
...(fromYear != null ? { fromYear } : {})
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN',
|
||||||
|
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO
|
|||||||
} else if (e.relationType === 'SPOUSE_OF') {
|
} else if (e.relationType === 'SPOUSE_OF') {
|
||||||
addToSet(spouses, e.personId, e.relatedPersonId);
|
addToSet(spouses, e.personId, e.relatedPersonId);
|
||||||
addToSet(spouses, e.relatedPersonId, e.personId);
|
addToSet(spouses, e.relatedPersonId, e.personId);
|
||||||
spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined);
|
spouseYear.set(
|
||||||
|
pairKey(e.personId, e.relatedPersonId),
|
||||||
|
e.fromDate ? Number(e.fromDate.slice(0, 4)) : undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId):
|
|||||||
relatedPersonId: childId,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +26,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
|
|||||||
relatedPersonId: b,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +39,9 @@ function siblingEdge(a: string, b: string, id = a + b): RelationshipDTO {
|
|||||||
relatedPersonId: b,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SIBLING_OF'
|
relationType: 'SIBLING_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
import { formatDatePart, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats one life date (birth or death) at the precision the data claims,
|
* Formats one life date (birth or death) at the precision the data claims.
|
||||||
* delegating all rendering to {@link formatDocumentDate}. Returns '' for a
|
* Thin domain alias over the shared {@link formatDatePart}: carries no * / †
|
||||||
* missing date. Carries no * / † glyph — components that need the glyphs wrap
|
* glyph — components that need the glyphs wrap them in their own `aria-hidden`
|
||||||
* them in their own `aria-hidden` markup so screen readers only hear the date.
|
* markup so screen readers only hear the date.
|
||||||
*
|
|
||||||
* A missing precision falls back to YEAR: pre-V76 rows only knew a year, and
|
|
||||||
* a bare year is the only safe rendering for a date without precision metadata.
|
|
||||||
*/
|
*/
|
||||||
export function formatLifeDate(
|
export function formatLifeDate(
|
||||||
date: string | null | undefined,
|
date: string | null | undefined,
|
||||||
precision: DatePrecision | null | undefined,
|
precision: DatePrecision | null | undefined,
|
||||||
locale?: string
|
locale?: string
|
||||||
): string {
|
): string {
|
||||||
if (!date) {
|
return formatDatePart(date, precision, locale);
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||