Compare commits

...
20 Commits
Author SHA1 Message Date
robin ab520c5ea3 Merge pull request 'Updated cv' (#28) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-14 10:44:07 +02:00
robin 0a6529c4f9 fix: updated cv
PR Checks / lint-and-test (pull_request) Successful in 1m28s
2026-09-14 10:39:12 +02:00
robin b90fc232b7 Merge pull request 'Re-order lists' (#27) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m17s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-11 20:08:56 +02:00
robin 7f66903d7d feat: re-order lists
PR Checks / lint-and-test (pull_request) Successful in 1m29s
2026-09-11 19:14:25 +02:00
robin 50aa249cf8 Merge pull request 'CV: spiced up interests section' (#26) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-09 17:30:40 +02:00
robin c212173692 feat: cv; spiced up interests section
PR Checks / lint-and-test (pull_request) Successful in 1m28s
2026-09-09 17:25:51 +02:00
robin a67189a493 Merge pull request 'CI now runs when opening a PR' (#25) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m16s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-09 17:11:02 +02:00
robin 7fcf761f4f feat: added PR checks (lint & test) and removed them from the deploy step
PR Checks / lint-and-test (pull_request) Successful in 1m28s
2026-09-09 17:00:54 +02:00
robin c2e656443f fix: missing '-' in linkedin link 2026-09-09 09:59:51 +02:00
robin 81c58d5ca0 Merge pull request 'CI now syncs dev to main' (#24) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 2m8s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-06 17:36:14 +02:00
robin 227dc06b58 fix: ci now syncs dev to main 2026-09-06 17:34:10 +02:00
robin 59cf4f1e2d Merge pull request 'Added deploy.yml for automatically building and deploying after merging to main' (#23) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 2m6s
2026-09-06 16:35:15 +02:00
robin 39b96c409e feat: added deploy.yml for automatically building and deploying after merging to main 2026-09-06 16:34:03 +02:00
robin 1f4b4a578e Merge pull request 'CV page improvements' (#22) from dev into main 2026-09-05 20:38:54 +02:00
robin edfaf98e18 fix: cv change, language rating bar width 2026-09-05 20:38:12 +02:00
robin ff1e078be9 fix cv page improvements 2026-09-05 20:30:52 +02:00
robin 7974cfafc7 feat: cv page now features "check out my work" section and small icons 2026-09-05 20:08:37 +02:00
robin cc010e79ca fix: linkedin link 2026-09-05 19:48:15 +02:00
robin 23029c8793 Merge pull request 'Added cv view (not indexed)' (#21) from dev into main 2026-09-05 10:46:11 +02:00
robin deb0bac072 feat: Added cv view (not indexed) 2026-09-05 10:45:26 +02:00
15 changed files with 1508 additions and 9 deletions
+73
View File
@@ -0,0 +1,73 @@
name: Build and Deploy
# Required secrets
# DEPLOY_SSH_KEY - private key for the deploy user
# DEPLOY_HOST - production host, e.g. YOUR_PROD_HOST
# DEPLOY_USER - ssh user, e.g. deploy
# DEPLOY_PATH - destination dir, e.g. /var/www/dttmr-fe
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Install system dependencies
run: apt-get update && apt-get install -y --no-install-recommends git openssh-client rsync ca-certificates
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
- name: Deploy via rsync
env:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts" \
dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH"
sync-dev:
needs: build-and-deploy
runs-on: ubuntu-latest
container:
image: node:24-bookworm
permissions:
contents: write
steps:
- name: Install system dependencies
run: apt-get update && apt-get install -y --no-install-recommends git
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fast-forward dev to main
run: |
git fetch origin dev:dev
git checkout dev
git merge --ff-only origin/main
git push origin dev
+24
View File
@@ -0,0 +1,24 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
lint-and-test:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run unit tests
run: npm run test:unit
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /cv
+8
View File
@@ -9,6 +9,7 @@ import type {
SetListItemTitlePayload,
AddUserToListPayload,
RemoveUserFromListPayload,
OrderListsPayload,
} from '@/types/list'
export async function getListsApi(): Promise<List[]> {
@@ -77,6 +78,13 @@ export async function removeUserFromListApi(payload: RemoveUserFromListPayload):
}
}
export async function orderListsApi(payload: OrderListsPayload): Promise<void> {
const response = await apiClient.post('/lists/order', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to reorder lists'))
}
}
export async function deleteListApi(listId: string): Promise<void> {
const response = await apiClient.delete(`/lists/${listId}`)
if (!response.ok) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue'
type IconName =
| 'phone'
| 'email'
| 'location'
| 'linkedin'
| 'github'
| 'code'
| 'verified'
| 'summary'
| 'experience'
| 'education'
| 'achievements'
| 'languages'
| 'skills'
| 'additional'
| 'interests'
| 'terminal'
| 'server'
| 'git-branch'
| 'cpu'
| 'mountain'
| 'dumbbell'
const props = defineProps<{ name: IconName }>()
// Sourced from lucide-static (ISC license): phone, mail, map-pin, linkedin,
// github, code, badge-check, file-text, briefcase, graduation-cap, award,
// languages, chart-column, layers, heart, terminal, server, git-branch, cpu,
// mountain, dumbbell.
const MARKUP: Record<IconName, string> = {
phone:
'<path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384" />',
email:
'<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" /><rect x="2" y="4" width="20" height="16" rx="2" />',
location:
'<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" /><circle cx="12" cy="10" r="3" />',
linkedin:
'<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" /><rect width="4" height="12" x="2" y="9" /><circle cx="4" cy="4" r="2" />',
github:
'<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" />',
code: '<path d="m16 18 6-6-6-6" /><path d="m8 6-6 6 6 6" />',
verified:
'<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" /><path d="m16 9-5.5 5.5L8 12" />',
summary:
'<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /><path d="M14 2v5a1 1 0 0 0 1 1h5" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />',
experience:
'<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" /><rect width="20" height="14" x="2" y="6" rx="2" />',
education:
'<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" /><path d="M22 10v6" /><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" />',
achievements:
'<path d="m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526" /><circle cx="12" cy="8" r="6" />',
languages:
'<path d="m5 8 6 6" /><path d="m4 14 6-6 2-3" /><path d="M2 5h12" /><path d="M7 2h1" /><path d="m22 22-5-10-5 10" /><path d="M14 18h6" />',
skills:
'<path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" />',
additional:
'<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" /><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" /><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />',
interests:
'<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5" />',
terminal: '<path d="M12 19h8" /><path d="m4 17 6-6-6-6" />',
server:
'<rect width="20" height="8" x="2" y="2" rx="2" ry="2" /><rect width="20" height="8" x="2" y="14" rx="2" ry="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" />',
'git-branch':
'<path d="M15 6a9 9 0 0 0-9 9V3" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" />',
cpu: '<path d="M12 20v2" /><path d="M12 2v2" /><path d="M17 20v2" /><path d="M17 2v2" /><path d="M2 12h2" /><path d="M2 17h2" /><path d="M2 7h2" /><path d="M20 12h2" /><path d="M20 17h2" /><path d="M20 7h2" /><path d="M7 20v2" /><path d="M7 2v2" /><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="8" y="8" width="8" height="8" rx="1" />',
mountain: '<path d="m8 3 4 8 5-5 5 15H2L8 3z" />',
dumbbell:
'<path d="M17.596 12.768a2 2 0 1 0 2.829-2.829l-1.768-1.767a2 2 0 0 0 2.828-2.829l-2.828-2.828a2 2 0 0 0-2.829 2.828l-1.767-1.768a2 2 0 1 0-2.829 2.829z" /><path d="m2.5 21.5 1.4-1.4" /><path d="m20.1 3.9 1.4-1.4" /><path d="M5.343 21.485a2 2 0 1 0 2.829-2.828l1.767 1.768a2 2 0 1 0 2.829-2.829l-6.364-6.364a2 2 0 1 0-2.829 2.829l1.768 1.767a2 2 0 0 0-2.828 2.829z" /><path d="m9.6 14.4 4.8-4.8" />',
}
const markup = computed(() => MARKUP[props.name])
</script>
<template>
<svg
class="cv-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
v-html="markup"
></svg>
</template>
<style scoped>
.cv-icon {
width: 1em;
height: 1em;
flex-shrink: 0;
}
</style>
+62 -2
View File
@@ -5,10 +5,11 @@ import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ list: LocalList }>()
const props = defineProps<{ list: LocalList; dragging?: boolean }>()
const emit = defineEmits<{
share: [list: LocalList]
delete: [list: LocalList]
'handle-pointerdown': [event: PointerEvent]
}>()
const listsStore = useListsStore()
@@ -49,7 +50,24 @@ function handleDelete(event: Event) {
</script>
<template>
<div class="list-card card">
<div class="list-card card" :class="{ 'is-dragging': dragging }">
<button
type="button"
class="grab-handle"
aria-label="Reorder list"
title="Drag to reorder"
@pointerdown="emit('handle-pointerdown', $event)"
>
<svg class="grab-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<circle cx="9" cy="6" r="1.6" />
<circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" />
<circle cx="15" cy="18" r="1.6" />
</svg>
</button>
<RouterLink :to="`/lists/${list.id}`" class="list-card-link">
<div class="list-card-main">
<h3>{{ list.name }}</h3>
@@ -144,6 +162,48 @@ function handleDelete(event: Event) {
border-color: var(--c-border-hover);
}
.list-card.is-dragging {
border-color: var(--c-accent-strong);
box-shadow: var(--shadow-md);
}
.grab-handle {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 44px;
margin: -0.5rem -0.2rem -0.5rem -0.5rem;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
cursor: grab;
touch-action: none;
-webkit-user-select: none;
user-select: none;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out;
}
.grab-handle:hover {
background-color: var(--c-bg-mute);
color: var(--c-heading);
}
.list-card.is-dragging .grab-handle {
cursor: grabbing;
color: var(--c-accent-strong);
}
.grab-icon {
display: block;
pointer-events: none;
}
.list-card-link {
display: flex;
align-items: center;
+129
View File
@@ -0,0 +1,129 @@
import { ref, type Ref } from 'vue'
import type { LocalList } from '@/database/db'
// Matches the CSS transition duration on `.list-row` (see ListsView.vue) -
// how long the dragged row's "snap into place" animation takes after drop,
// before handing the final order back to the caller.
const SETTLE_MS = 220
interface SlotRect {
top: number
height: number
}
// Drag-to-reorder for the lists overview page. Deliberately built on raw
// Pointer Events rather than HTML5 drag-and-drop, which has no usable touch
// story - this needs to work as a one-finger drag on a phone first.
//
// The dragged row stays in the v-for (rather than becoming a floating
// clone): its transform is computed each pointermove as the delta between
// where the pointer says it should be and where it currently sits in the
// (already reordered) array. Because `items` gets live-spliced as the
// pointer crosses sibling midpoints, that natural position jumps in whole
// slot-steps while the transform absorbs the remainder - the row tracks the
// finger with no lag and no double-counted offset. Every other row's shift
// is left entirely to <TransitionGroup>'s built-in FLIP move animation.
export function useListDragReorder(
items: Ref<LocalList[]>,
onReorder: (orderedIds: string[]) => void,
) {
const draggingId = ref<string | null>(null)
const isPointerActive = ref(false)
const dragOffsetPx = ref(0)
const itemEls = new Map<string, HTMLElement>()
let slots: SlotRect[] = []
let startClientY = 0
let startTop = 0
let startHeight = 0
let activePointerId: number | null = null
let settleTimeout: ReturnType<typeof setTimeout> | null = null
function setItemRef(id: string, el: Element | null) {
if (el) itemEls.set(id, el as HTMLElement)
else itemEls.delete(id)
}
function onPointerDown(id: string, event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return
const el = itemEls.get(id)
if (!el) return
if (settleTimeout !== null) {
clearTimeout(settleTimeout)
settleTimeout = null
}
event.preventDefault()
activePointerId = event.pointerId
// Fixed physical slot positions for this drag, captured once up front.
// Reordering `items` mid-drag doesn't shift these - slot i always means
// "the i-th row's on-screen position", independent of which id currently
// occupies it - so the target-slot math below never has to re-measure a
// layout our own reordering just changed.
slots = items.value.map((item) => {
const itemEl = itemEls.get(item.id)
const rect = itemEl?.getBoundingClientRect()
return { top: rect?.top ?? 0, height: rect?.height ?? 0 }
})
const rect = el.getBoundingClientRect()
startTop = rect.top
startHeight = rect.height
startClientY = event.clientY
draggingId.value = id
isPointerActive.value = true
dragOffsetPx.value = 0
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!isPointerActive.value || event.pointerId !== activePointerId) return
event.preventDefault()
const desiredTop = startTop + (event.clientY - startClientY)
const draggedCenter = desiredTop + startHeight / 2
let targetIndex = slots.findIndex((slot) => draggedCenter < slot.top + slot.height / 2)
if (targetIndex === -1) targetIndex = slots.length - 1
const currentIndex = items.value.findIndex((item) => item.id === draggingId.value)
if (currentIndex !== -1 && targetIndex !== currentIndex) {
const reordered = [...items.value]
const moved = reordered.splice(currentIndex, 1)[0]
if (moved) {
reordered.splice(targetIndex, 0, moved)
items.value = reordered
}
}
const settledIndex = items.value.findIndex((item) => item.id === draggingId.value)
dragOffsetPx.value = desiredTop - (slots[settledIndex]?.top ?? startTop)
}
function onPointerUp(event: PointerEvent) {
if (!isPointerActive.value || event.pointerId !== activePointerId) return
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
activePointerId = null
isPointerActive.value = false
// Let the row's CSS transition animate the residual offset back to 0
// (see ListsView.vue: transitions are suppressed only while
// isPointerActive), then hand back the final order.
dragOffsetPx.value = 0
const finalIds = items.value.map((item) => item.id)
settleTimeout = setTimeout(() => {
settleTimeout = null
draggingId.value = null
onReorder(finalIds)
}, SETTLE_MS)
}
return { draggingId, isPointerActive, dragOffsetPx, setItemRef, onPointerDown }
}
+2
View File
@@ -6,6 +6,7 @@ import type {
CreateListItemPayload,
SetListItemCompletedPayload,
SetListItemTitlePayload,
OrderListsPayload,
} from '@/types/list'
export interface LocalList extends List {
@@ -40,6 +41,7 @@ type SyncOperationPayloads = {
setListItemCompleted: SetListItemCompletedPayload
deleteList: DeleteListPayload
deleteListItem: DeleteListItemPayload
orderLists: OrderListsPayload
}
export type SyncOperationType = keyof SyncOperationPayloads
+5
View File
@@ -29,6 +29,11 @@ const router = createRouter({
name: 'login',
component: () => import('../views/LoginView.vue'),
},
{
path: '/cv',
name: 'cv',
component: () => import('../views/CvView.vue'),
},
{
path: '/account',
name: 'account',
+98
View File
@@ -115,6 +115,7 @@ const listsApiMocks = vi.hoisted(() => ({
removeUserFromListApi: vi.fn<() => Promise<unknown>>(),
deleteListApi: vi.fn<() => Promise<unknown>>(),
deleteListItemApi: vi.fn<() => Promise<unknown>>(),
orderListsApi: vi.fn<() => Promise<unknown>>(),
}))
vi.mock('@/api/lists', () => listsApiMocks)
@@ -531,6 +532,103 @@ describe('useListsStore', () => {
expect(store.pendingCount).toBe(0)
})
it('sorts lists by position, breaking ties (e.g. two lists both at position 0) by newest first', async () => {
const store = useListsStore()
await fakeDb.lists.put({
id: 'list-b',
name: 'B',
position: 1,
created_at: '2024-01-01T00:00:00.000Z',
pendingSync: false,
})
await fakeDb.lists.put({
id: 'list-a',
name: 'A',
position: 0,
created_at: '2024-01-02T00:00:00.000Z',
pendingSync: false,
})
// Newly created lists always come back from the server at position 0 (so
// a new list is always on top), so a not-yet-synced local list (no
// position of its own yet, defaulting to 0) must also win the tiebreak
// against any existing position-0 list by virtue of being newest.
await fakeDb.lists.put({
id: 'list-new',
name: 'New',
created_at: '2024-01-03T00:00:00.000Z',
pendingSync: true,
})
await store.refresh()
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-new', 'list-a', 'list-b'])
})
it('reorders lists optimistically and pushes the new order via the dedicated endpoint', async () => {
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
listsApiMocks.getListsApi.mockResolvedValueOnce([
{ id: 'list-a', name: 'A', position: 1 },
{ id: 'list-b', name: 'B', position: 0 },
])
const store = useListsStore()
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
await store.refresh()
await store.reorderLists(['list-b', 'list-a'])
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-b', 'list-a'])
expect(store.lists.every((list) => list.pendingSync)).toBe(true)
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-b', 'list-a'] })
expect(store.lists.find((list) => list.id === 'list-a')?.pendingSync).toBe(false)
expect(store.lists.find((list) => list.id === 'list-b')?.pendingSync).toBe(false)
expect(store.pendingCount).toBe(0)
})
it('supersedes a stale queued reorder instead of replaying both', async () => {
listsApiMocks.orderListsApi.mockResolvedValue(undefined)
const store = useListsStore()
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
await store.refresh()
await store.reorderLists(['list-b', 'list-a'])
await store.reorderLists(['list-a', 'list-b'])
expect(store.pendingCount).toBe(1)
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledTimes(1)
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-a', 'list-b'] })
})
it('remaps a queued reorder entry when one of its lists gets its server id assigned mid-flight', async () => {
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-list-1', name: 'New list' })
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
listsApiMocks.getListsApi.mockResolvedValueOnce([
{ id: 'server-list-1', name: 'New list', position: 0 },
{ id: 'list-a', name: 'A', position: 1 },
])
const store = useListsStore()
const localList = await store.createList('New list')
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await store.refresh()
// Queues an "orderLists" entry referencing the not-yet-synced localList.id,
// created after the still-pending "createList" entry.
await store.reorderLists([localList.id, 'list-a'])
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({
list_ids: ['server-list-1', 'list-a'],
})
})
it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => {
let resolveGetLists!: (value: unknown[]) => void
listsApiMocks.getListsApi.mockImplementationOnce(
+76 -2
View File
@@ -19,6 +19,7 @@ import {
removeUserFromListApi,
deleteListApi,
deleteListItemApi,
orderListsApi,
} from '@/api/lists'
// A burst of rapid edits (ticking off several items, typing then blurring a
@@ -42,8 +43,18 @@ export const useListsStore = defineStore('lists', () => {
const error = ref<string | null>(null)
const pendingCount = ref(0)
// Lists carry a server-assigned `position`, rearranged via reorderLists().
// New lists (local-only or freshly synced) always come back as position 0
// - by design, so a new list always lands at the top - which means several
// lists can share a position. created_at desc breaks that tie, newest
// first, and also covers a local list created but not yet synced (no
// position of its own yet: defaults to 0 below).
const sortedLists = computed(() =>
[...lists.value].sort((a, b) => (b.modified_at ?? '').localeCompare(a.modified_at ?? '')),
[...lists.value].sort((a, b) => {
const positionDiff = (a.position ?? 0) - (b.position ?? 0)
if (positionDiff !== 0) return positionDiff
return (b.created_at ?? '').localeCompare(a.created_at ?? '')
}),
)
function itemsForList(listId: string) {
@@ -245,6 +256,37 @@ export const useListsStore = defineStore('lists', () => {
scheduleSync()
}
// Applies a full reordering of the user's lists (e.g. from a drag-and-drop
// gesture). Positions are reassigned optimistically to every list in
// `orderedIds`, and marked pendingSync so a pull racing the queued
// "orderLists" entry can't clobber the optimistic order before it syncs.
async function reorderLists(orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => db.lists.update(id, { position: index, pendingSync: true })),
)
for (const [index, id] of orderedIds.entries()) {
const existing = lists.value.find((entry) => entry.id === id)
if (existing) {
existing.position = index
existing.pendingSync = true
}
}
// Only the latest requested order matters, so any not-yet-synced
// "orderLists" entry is superseded rather than left to also replay.
const staleEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
if (staleEntries.length > 0) {
await db.syncQueue.bulkDelete(staleEntries.map((entry) => entry.id!))
pendingCount.value = Math.max(0, pendingCount.value - staleEntries.length)
}
await enqueue({
type: 'orderLists',
payload: { list_ids: orderedIds },
})
scheduleSync()
}
async function deleteListItem(itemId: string) {
await db.listItems.delete(itemId)
removeLocalListItem(itemId)
@@ -292,6 +334,18 @@ export const useListsStore = defineStore('lists', () => {
payload: updatedPayload,
})
}
// "orderLists" entries aren't tied to a single localListId (they carry
// every list's id in payload.list_ids), so they need their own remap pass.
const affectedOrderEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
for (const orderEntry of affectedOrderEntries) {
if (orderEntry.type !== 'orderLists' || !orderEntry.payload.list_ids.includes(oldId)) continue
await db.syncQueue.update(orderEntry.id!, {
payload: {
list_ids: orderEntry.payload.list_ids.map((id) => (id === oldId ? newId : id)),
},
})
}
}
// Remaps a client-generated temporary list item id to the id assigned by
@@ -374,6 +428,17 @@ export const useListsStore = defineStore('lists', () => {
await deleteListItemApi(entry.payload.id)
break
}
case 'orderLists': {
await orderListsApi(entry.payload)
await Promise.all(
entry.payload.list_ids.map((id) => db.lists.update(id, { pendingSync: false })),
)
const affectedIds = new Set(entry.payload.list_ids)
for (const list of lists.value) {
if (affectedIds.has(list.id)) list.pendingSync = false
}
break
}
}
}
@@ -384,7 +449,15 @@ export const useListsStore = defineStore('lists', () => {
try {
const queue = await db.syncQueue.orderBy('createdAt').toArray()
for (const entry of queue) {
for (const snapshotEntry of queue) {
// Re-read the entry rather than trusting the queue snapshot: an
// earlier iteration this same pass may have remapped a temporary id
// referenced in this entry's payload (e.g. remapListId rewriting a
// queued "orderLists" entry after its "createList" entry synced).
const entry =
snapshotEntry.id !== undefined
? ((await db.syncQueue.get(snapshotEntry.id)) ?? snapshotEntry)
: snapshotEntry
try {
await processSyncEntry(entry)
if (entry.id !== undefined) {
@@ -572,6 +645,7 @@ export const useListsStore = defineStore('lists', () => {
removeUserFromList,
deleteList,
deleteListItem,
reorderLists,
sync,
pullFromServer,
pullListItems,
+5
View File
@@ -5,6 +5,7 @@ export interface List {
modified_at?: string
total_items?: number
completed_items?: number
position?: number
}
export interface ListItem {
@@ -42,3 +43,7 @@ export interface RemoveUserFromListPayload {
list_id: string
email: string
}
export interface OrderListsPayload {
list_ids: string[]
}
+872
View File
@@ -0,0 +1,872 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import cvPhoto from '@/assets/cv-photo.jpg'
import CvIcon from '@/components/CvIcon.vue'
// This is a public sub-page with no link from the rest of the app, and it
// shouldn't show up in search results. index.html is shared across every
// route, so the noindex meta tag has to be added/removed here rather than
// living statically in the HTML head.
let robotsMeta: HTMLMetaElement | null = null
onMounted(() => {
robotsMeta = document.createElement('meta')
robotsMeta.name = 'robots'
robotsMeta.content = 'noindex, nofollow'
document.head.appendChild(robotsMeta)
})
onBeforeUnmount(() => {
robotsMeta?.remove()
})
interface Project {
title: string
repo: string
desc: string
url: string
}
interface ExperienceEntry {
company: string
location: string
title: string
dates: string
subtitle: string
bullets: string[]
}
interface EducationEntry {
school: string
dates: string
degree: string
}
interface Achievement {
title: string
desc: string
}
interface Language {
name: string
level: string
rating: number
}
interface Skill {
name: string
rating: number
}
interface AdditionalExperience {
title: string
desc: string
}
interface Interest {
name: string
icon: 'terminal' | 'server' | 'git-branch' | 'cpu' | 'mountain' | 'dumbbell'
}
const SKILL_MAX = 8
const LANGUAGE_MAX = 5
const projects: Project[] = [
{
title: 'Frontend',
repo: 'dttmr-fe',
desc: 'Offline-first PWA for shared, syncable to-do lists and tracking workouts. Vue 3 + TypeScript.',
url: 'https://git.dittmar.dev/robin/dttmr-fe',
},
{
title: 'API',
repo: 'dttmr-api',
desc: 'Go backend powering auth and sync for the to-do list/workout tracking app.',
url: 'https://git.dittmar.dev/robin/dttmr-api',
},
]
const experience: ExperienceEntry[] = [
{
company: 'Greenbone AG',
location: 'Remote, Germany',
title: 'Senior C Software Engineer',
dates: '08/2025 - Present',
subtitle: 'Open-Source Vulnerability Management',
bullets: [
"Improved vulnerability management for customers and community users by working on open- and closed-source projects like 'gvmd', 'gsad', and 'gsa'.",
'Maintained old legacy systems in C, as well as built new components in Rust, which align with the ecosystem.',
'Improved GitHub Actions CI/CD pipelines.',
],
},
{
company: 'Aspen Technology',
location: 'Cologne, Germany',
title: 'Sr Software Developer',
dates: '04/2025 - 07/2025',
subtitle: 'Asset Optimization Software',
bullets: [
'Maintenance work and feature development for "inmation" Backend, a scalable solution for asset optimization.',
],
},
{
company: 'Ubisoft',
location: 'Düsseldorf, Germany',
title: 'Programmer',
dates: '10/2020 - 07/2024',
subtitle: 'AAA Game Developer/Publisher',
bullets: [
'Designed and implemented new backend services in TypeScript/NodeJS, resulting in less development time for business features.',
'Overseen the architecture and deployment of software solutions handling over 600k concurrent users.',
'Reduced processing time by 30% by optimizing database queries and scripts.',
'Improved system stability by 10%, resulting in increased user engagement and satisfaction.',
'Enhanced C++ Backend functionality, resulting in a 5% decrease in response times.',
],
},
{
company: '11880 Internet Services AG',
location: 'Essen, Germany',
title: 'C++ Developer',
dates: '06/2018 - 09/2020',
subtitle: '11880.com Information Portal',
bullets: [
'Maintained and developed new features of an in-memory search engine written in C++.',
'Lifting on-premises services to the cloud, resulting in 30% more uptime and reduced costs.',
],
},
{
company: 'Fluxatron Softec GmbH',
location: 'Oberhausen, Germany',
title: 'Software Developer Working Student',
dates: '03/2016 - 09/2017',
subtitle: 'Software Startup',
bullets: [
'Implement and design add-ons for Dynamics AX 365 for Business and Operations using X++.',
'Enabled operational efficiency by reducing feature deployment time by 20% through end-to-end feature implementation collaboration.',
],
},
]
const education: EducationEntry[] = [
{
school: 'Hochschule Ruhr West University of Applied Sciences',
dates: '10/2015 - 05/2018',
degree: 'Applied Informatics (no degree)',
},
{
school: 'Hans-Sachs Berufskolleg',
dates: '08/2012 - 06/2015',
degree: 'Fachabitur + Informationstechnischer Assistent',
},
]
const achievements: Achievement[] = [
{
title: 'Design New Services',
desc: 'Successfully implemented two new services within three months.',
},
{
title: 'Server Optimization Success',
desc: 'Reduced server downtime by 10% by optimizing code.',
},
{
title: 'Performance Enhancement Achievement',
desc: 'Improved application performance by 50% through code refactoring.',
},
]
const languages: Language[] = [
{ name: 'German', level: 'Native', rating: 5 },
{ name: 'English', level: 'Proficient', rating: 4 },
{ name: 'Spanish', level: 'Beginner', rating: 1 },
]
const interests: Interest[] = [
{ name: 'Software Development', icon: 'terminal' },
{ name: 'Linux', icon: 'server' },
{ name: 'Open Source', icon: 'git-branch' },
{ name: 'Raspberry Pi', icon: 'cpu' },
{ name: 'Rock climbing', icon: 'mountain' },
{ name: 'Calisthenics', icon: 'dumbbell' },
]
const skills: Skill[] = [
{ name: 'Golang', rating: 6 },
{ name: 'C++17', rating: 8 },
{ name: 'C++20', rating: 5 },
{ name: 'C++ STL', rating: 7 },
{ name: 'C++ boost', rating: 4 },
{ name: 'C++ POCO', rating: 4 },
{ name: 'C (C89 to C11)', rating: 7 },
{ name: 'SQL', rating: 5 },
{ name: 'TypeScript', rating: 5 },
{ name: 'Docker', rating: 6 },
{ name: 'Linux', rating: 7 },
{ name: 'git', rating: 7 },
{ name: 'HTTP/REST', rating: 7 },
{ name: 'Amazon Web Services (AWS)', rating: 4 },
{ name: 'Node.js', rating: 4 },
{ name: 'Vue.js', rating: 5 },
{ name: 'CMake', rating: 5 },
{ name: 'MongoDB', rating: 4 },
{ name: 'Python', rating: 6 },
{ name: 'Rust', rating: 4 },
{ name: 'C#', rating: 3 },
{ name: 'ELK stack', rating: 3 },
{ name: 'DevOps', rating: 5 },
{ name: 'CI/CD with gitlab/jenkins', rating: 6 },
{ name: 'GitHub Actions', rating: 6 },
{ name: 'Jetbrains IDEs', rating: 7 },
{ name: 'VSCode', rating: 5 },
{ name: 'Visual Studio', rating: 4 },
{ name: 'vim/neovim', rating: 5 },
{ name: 'perf', rating: 5 },
{ name: 'valgrind', rating: 4 },
{ name: 'C++ SFML', rating: 6 },
{ name: 'HTML5/CSS3', rating: 4 },
{ name: 'Claude Code', rating: 4 },
]
const additionalExperiences: AdditionalExperience[] = [
{
title: 'Minecraft Mod',
desc: 'Around 2012 I have developed and published the "Blood Mod" for Minecraft. The mod was pretty popular at the time, being downloaded thousands of times.',
},
{
title: 'Buy script generator',
desc: 'Around 2013, I have developed a tool to generate so-called "buy scripts" for Counter-Strike. This was my first experience creating C++ GUI applications, using the Win32 API.',
},
{
title: 'Small 2D games',
desc: 'Around 2014-2015 I became interested in game development and have created a few simple 2D games using C++ and SFML.',
},
{
title: 'Self-hosted services',
desc: 'For years, I have been hosting my own git and password manager at home, utilizing a Raspberry Pi with Docker. In addition, I have a VPS with nginx and WireGuard, for access from anywhere around the world.',
},
]
</script>
<template>
<main class="page cv-page">
<div class="profile-top">
<img class="avatar" :src="cvPhoto" alt="Portrait of Robin Dittmar" />
<div>
<h1>Robin Dittmar</h1>
<span class="role-badge">Software Developer</span>
</div>
</div>
<div class="ledger-rule" aria-hidden="true"></div>
<section class="card info-card contact-card">
<h4>Contacts</h4>
<p class="row">
<span class="row-label"><CvIcon name="phone" />Phone</span>
<a class="mono-num" href="tel:+4915901668799">+49 1590 1668799</a>
</p>
<p class="row">
<span class="row-label"><CvIcon name="email" />Email</span>
<a href="mailto:robindittmar@gmail.com">robindittmar@gmail.com</a>
</p>
<p class="row">
<span class="row-label"><CvIcon name="location" />Location</span>
<strong>Düsseldorf</strong>
</p>
<p class="row">
<span class="row-label"><CvIcon name="linkedin" />LinkedIn</span>
<a
href="https://www.linkedin.com/in/robin-dittmar-948424310/"
target="_blank"
rel="noopener"
>robin-dittmar</a
>
</p>
<p class="row">
<span class="row-label"><CvIcon name="github" />GitHub</span>
<a href="https://github.com/rdttmr" target="_blank" rel="noopener">rdttmr</a>
</p>
</section>
<h1 class="section-heading"><CvIcon name="code" />Check Out My Work</h1>
<ul class="cards">
<li v-for="project in projects" :key="project.repo">
<a class="card project-card" :href="project.url" target="_blank" rel="noopener">
<span class="project-icon"><CvIcon name="code" /></span>
<span class="project-body">
<span class="project-title">
{{ project.title }}
<span class="mono-num project-repo">{{ project.repo }}</span>
</span>
<span class="project-desc">{{ project.desc }}</span>
</span>
</a>
</li>
</ul>
<h1 class="section-heading"><CvIcon name="summary" />Summary</h1>
<section class="card info-card">
<p class="summary-text">
Backend engineer with 8+ years of experience building services where performance and reliability matter.
From AAA game backends serving 600k+ concurrent users to open-source vulnerability management.
I work primarily in Go and TypeScript today, with deep C/C++ experience underneath.
I care about clean architecture, automated testing, and owning services end-to-end through
deployment and monitoring.
</p>
</section>
<h1 class="section-heading"><CvIcon name="experience" />Experience</h1>
<ul class="cards timeline timeline-current">
<li v-for="job in experience" :key="job.company + job.dates">
<article class="card job-card">
<div class="job-card-head">
<h3>{{ job.company }}</h3>
<span class="mono-num job-dates">{{ job.dates }}</span>
</div>
<p class="job-title">{{ job.title }} · {{ job.location }}</p>
<p class="job-subtitle">{{ job.subtitle }}</p>
<ul class="job-bullets">
<li v-for="(bullet, idx) in job.bullets" :key="idx">{{ bullet }}</li>
</ul>
</article>
</li>
</ul>
<h1 class="section-heading"><CvIcon name="education" />Education</h1>
<ul class="cards timeline">
<li v-for="edu in education" :key="edu.school">
<article class="card job-card">
<div class="job-card-head">
<h3>{{ edu.school }}</h3>
<span class="mono-num job-dates">{{ edu.dates }}</span>
</div>
<p class="job-subtitle">{{ edu.degree }}</p>
</article>
</li>
</ul>
<h1 class="section-heading"><CvIcon name="achievements" />Key Achievements</h1>
<section class="card info-card">
<div v-for="item in achievements" :key="item.title" class="achievement-row">
<span class="achv-icon"><CvIcon name="verified" /></span>
<div>
<h4>{{ item.title }}</h4>
<p>{{ item.desc }}</p>
</div>
</div>
</section>
<h1 class="section-heading"><CvIcon name="languages" />Languages</h1>
<section class="card info-card">
<div v-for="lang in languages" :key="lang.name" class="rating-row">
<p class="row">
<span>{{ lang.name }}</span>
<span class="tag">{{ lang.level }}</span>
</p>
<div class="tally tally-lg">
<span
v-for="n in LANGUAGE_MAX"
:key="n"
class="tally-tick"
:class="{ filled: n <= lang.rating }"
></span>
</div>
</div>
</section>
<h1 class="section-heading"><CvIcon name="skills" />Industry Experience</h1>
<section class="card info-card">
<div class="skill-grid">
<div v-for="skill in skills" :key="skill.name" class="skill-row">
<span class="skill-name">{{ skill.name }}</span>
<div class="tally">
<span
v-for="n in SKILL_MAX"
:key="n"
class="tally-tick"
:class="{ filled: n <= skill.rating }"
></span>
</div>
</div>
</div>
</section>
<h1 class="section-heading"><CvIcon name="additional" />Additional Experience</h1>
<ul class="cards">
<li v-for="item in additionalExperiences" :key="item.title">
<article class="card job-card">
<h3>{{ item.title }}</h3>
<p class="job-subtitle">{{ item.desc }}</p>
</article>
</li>
</ul>
<h1 class="section-heading"><CvIcon name="interests" />Interests</h1>
<section class="card info-card">
<div class="interests">
<div v-for="interest in interests" :key="interest.name" class="interest-chip">
<span class="interest-icon"><CvIcon :name="interest.icon" /></span>
<span>{{ interest.name }}</span>
</div>
</div>
</section>
</main>
</template>
<style scoped>
.cv-page {
padding-bottom: 2.5rem;
}
@media (min-width: 768px) {
.cv-page {
max-width: 780px;
}
}
.cv-page > h1:first-child {
font-size: 1.35rem;
}
.profile-top {
display: flex;
align-items: center;
gap: 1.1rem;
margin-bottom: 1rem;
}
.profile-top h1 {
font-size: 1.4rem;
margin-bottom: 0.45rem;
letter-spacing: 0.01em;
}
.avatar {
display: block;
width: 76px;
height: 76px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--c-border);
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.role-badge {
display: inline-flex;
padding: 0.22rem 0.65rem;
border-radius: 3px;
background: var(--c-accent-bg);
color: var(--c-accent-strong);
font-size: 0.78rem;
font-weight: 500;
}
.ledger-rule {
height: 4px;
margin-bottom: 1.5rem;
border-top: 1.5px solid var(--c-border-hover);
border-bottom: 1px dashed var(--c-border);
}
.section-heading {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 1.08rem;
font-weight: 600;
margin: 2rem 0 0.85rem;
}
.section-heading::after {
content: '';
flex: 1;
height: 1px;
background: var(--c-border);
margin-left: 0.35rem;
}
.section-heading .cv-icon {
font-size: 0.85rem;
color: var(--c-accent-strong);
}
.card {
border-radius: 4px;
box-shadow: none;
}
.info-card {
padding: 1rem 1.1rem;
}
.info-card h4 {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.contact-card {
border-color: var(--c-accent-soft);
box-shadow: 0 10px 28px rgba(61, 114, 180, 0.22);
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--c-text);
}
.row:last-child {
margin-bottom: 0;
}
.info-card > .row {
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
border-bottom: 1px dashed var(--c-border);
}
.info-card > .row:last-child {
padding-bottom: 0;
margin-bottom: 0;
border-bottom: none;
}
.row strong {
color: var(--c-heading);
}
.row-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.row-label .cv-icon {
color: var(--c-text-soft);
}
.row a {
color: var(--c-accent-strong);
}
.summary-text {
font-size: 0.88rem;
color: var(--c-text);
}
.achievement-row {
display: flex;
gap: 0.7rem;
align-items: flex-start;
}
.achievement-row + .achievement-row {
margin-top: 0.85rem;
padding-top: 0.85rem;
border-top: 1px dashed var(--c-border);
}
.achv-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
flex-shrink: 0;
margin-top: 0.05rem;
border-radius: 50%;
background: var(--c-accent-bg);
color: var(--c-accent-strong);
font-size: 0.9rem;
}
.achievement-row h4 {
font-size: 0.85rem;
color: var(--c-heading);
margin-bottom: 0.15rem;
}
.achievement-row p {
font-size: 0.82rem;
color: var(--c-text-soft);
}
.rating-row + .rating-row {
margin-top: 0.85rem;
}
/* rating tallies */
.tally {
display: flex;
gap: 3px;
}
.tally-tick {
flex: 1;
height: 6px;
border-radius: 1px;
background: var(--c-bg-mute);
}
.tally-tick.filled {
background: var(--c-accent);
}
.tally-lg {
max-width: 82%;
}
.tally-lg .tally-tick {
height: 7px;
}
/* cards list (experience / education / additional experience) */
.cards {
display: flex;
flex-direction: column;
gap: 0.6rem;
list-style: none;
padding: 0;
margin: 0;
}
/* timeline (experience / education): a real chronological record, so the
rail + nodes carry meaning rather than decorate */
.timeline {
position: relative;
gap: 0.9rem;
padding-left: 1.1rem;
}
.timeline::before {
content: '';
position: absolute;
left: 3px;
top: 0.6rem;
bottom: 0.6rem;
width: 1px;
background: var(--c-border-hover);
}
.timeline > li {
position: relative;
}
.timeline > li::before {
content: '';
position: absolute;
left: -1.1rem;
top: 1.1rem;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-bg);
border: 1.5px solid var(--c-text-soft);
}
.timeline-current > li:first-child::before {
border-color: var(--c-accent-strong);
background: var(--c-accent-strong);
box-shadow: 0 0 0 3px var(--c-accent-bg);
}
.job-card {
padding: 0.9rem 1.1rem;
}
.job-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.job-card-head h3 {
font-size: 1.02rem;
}
.job-dates {
font-size: 0.75rem;
color: var(--c-text-soft);
white-space: nowrap;
}
.job-title {
font-size: 0.85rem;
color: var(--c-text);
margin-top: 0.15rem;
}
.job-subtitle {
font-size: 0.82rem;
font-style: italic;
color: var(--c-text-soft);
margin-top: 0.3rem;
}
.job-bullets {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.6rem;
font-size: 0.85rem;
color: var(--c-text);
}
.job-bullets li {
position: relative;
padding-left: 0.9rem;
}
.job-bullets li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--c-accent);
}
/* project links */
.project-card {
display: flex;
align-items: center;
gap: 0.9rem;
padding: 0.9rem 1.1rem;
border: 1px solid var(--c-accent-soft);
transition:
transform 0.15s ease-in-out,
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.project-card:hover,
.project-card:focus-visible {
border-color: var(--c-accent-strong);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.project-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
flex-shrink: 0;
border-radius: var(--radius-md);
background: var(--c-accent-bg);
color: var(--c-accent-strong);
font-size: 1.1rem;
}
.project-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.project-title {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-weight: 600;
color: var(--c-heading);
font-size: 0.95rem;
}
.project-repo {
font-size: 0.75rem;
color: var(--c-accent-strong);
}
.project-desc {
font-size: 0.82rem;
color: var(--c-text-soft);
}
/* skills */
.skill-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.7rem;
}
@media (min-width: 560px) {
.skill-grid {
grid-template-columns: 1fr 1fr;
gap: 0.7rem 1.5rem;
}
}
.skill-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.skill-name {
font-size: 0.82rem;
color: var(--c-text);
}
/* interests */
.interests {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.interest-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.8rem 0.3rem 0.35rem;
border-radius: 999px;
border: 1px solid var(--c-border);
background: var(--c-bg-soft);
font-size: 0.8rem;
color: var(--c-text);
transition:
border-color 0.15s ease-in-out,
transform 0.15s ease-in-out;
}
.interest-chip:hover {
border-color: var(--c-accent-soft);
transform: translateY(-1px);
}
.interest-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
border-radius: 50%;
background: var(--c-accent-bg);
color: var(--c-accent-strong);
font-size: 0.75rem;
}
/* tags */
.tag {
display: inline-flex;
font-size: 0.72rem;
font-weight: 500;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background-color: var(--c-bg-mute);
color: var(--c-text-soft);
}
</style>
+55 -5
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useListsStore } from '@/stores/lists'
import type { LocalList } from '@/database/db'
import ListCard from '@/components/ListCard.vue'
import ShareListModal from '@/components/ShareListModal.vue'
import DeleteListModal from '@/components/DeleteListModal.vue'
import { useListDragReorder } from '@/composables/useListDragReorder'
const listsStore = useListsStore()
@@ -14,6 +15,26 @@ const createError = ref('')
const sharingList = ref<LocalList | null>(null)
const deletingList = ref<LocalList | null>(null)
// Local, reorderable copy of the store's list order. Kept in sync with
// listsStore.sortedLists except while a drag is in progress, so a
// mid-sync-pass update (e.g. total_items ticking over) can't yank a row out
// from under the user's finger.
const displayedLists = ref<LocalList[]>([])
const { draggingId, isPointerActive, dragOffsetPx, setItemRef, onPointerDown } = useListDragReorder(
displayedLists,
(orderedIds) => {
void listsStore.reorderLists(orderedIds)
},
)
watch(
() => listsStore.sortedLists,
(next) => {
if (draggingId.value === null) displayedLists.value = [...next]
},
{ immediate: true },
)
onMounted(() => {
listsStore.loadLists()
})
@@ -86,11 +107,24 @@ async function handleCreateList() {
<p v-if="createError" class="banner banner-error">{{ createError }}</p>
<ul v-if="listsStore.sortedLists.length > 0" class="lists">
<li v-for="list in listsStore.sortedLists" :key="list.id">
<ListCard :list="list" @share="handleOpenShare(list)" @delete="handleOpenDelete(list)" />
<TransitionGroup v-if="displayedLists.length > 0" tag="ul" name="list-reorder" class="lists">
<li
v-for="list in displayedLists"
:key="list.id"
:ref="(el) => setItemRef(list.id, el as Element | null)"
class="list-row"
:class="{ 'no-transition': isPointerActive && draggingId === list.id }"
:style="draggingId === list.id ? { transform: `translateY(${dragOffsetPx}px)` } : undefined"
>
<ListCard
:list="list"
:dragging="draggingId === list.id"
@share="handleOpenShare(list)"
@delete="handleOpenDelete(list)"
@handle-pointerdown="onPointerDown(list.id, $event)"
/>
</li>
</ul>
</TransitionGroup>
<div v-else class="empty-state">
<p>No lists yet</p>
@@ -145,6 +179,22 @@ h1 {
margin: 0;
}
.list-row {
transition:
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
z-index 0s;
}
.list-row.no-transition {
transition: none;
z-index: 2;
position: relative;
}
.list-reorder-move {
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;