Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0447317b | ||
|
|
a2f6e9bda3 | ||
|
|
1bbd36c79b | ||
|
|
54359f8d68 | ||
|
|
f400705bb7 | ||
|
|
0dc4c2f9b8 | ||
|
|
7f09b35db5 | ||
|
|
0f4048d8f2 |
@@ -72,13 +72,44 @@ jobs:
|
||||
#
|
||||
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
|
||||
# side, der linker til sig selv, hjælper ingen.
|
||||
#
|
||||
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
|
||||
# actions/checkout henter først alle tags korrekt — og overskriver derefter
|
||||
# netop den ene, der udløste kørslen:
|
||||
#
|
||||
# git rev-parse refs/tags/v0.46.0
|
||||
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
|
||||
#
|
||||
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
|
||||
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
|
||||
# commitbeskeden, og det var flettecommittens overskrift, der stod som
|
||||
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
|
||||
#
|
||||
# Derfor hentes tagget som *ref* igen her, før det læses.
|
||||
- id: notes
|
||||
run: |
|
||||
git fetch --force origin \
|
||||
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
|
||||
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||||
|
||||
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
|
||||
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
|
||||
# frem for at blive opdaget på siden bagefter.
|
||||
case "$body" in
|
||||
"${GITHUB_REF_NAME}"*) ;;
|
||||
*)
|
||||
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
|
||||
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo 'body<<VERDANDE_EOF'
|
||||
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
|
||||
printf '%s\n' "$body"
|
||||
echo VERDANDE_EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
@@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline
|
||||
it is a document that can run script, and a note is not a place to run somebody
|
||||
else's.
|
||||
|
||||
**Click one to see it whole.** In the note it is scaled down to the width of the
|
||||
column — a photo off a phone is four thousand pixels across — so what you are
|
||||
looking at is a thumbnail that does not look like one. The full-size view has a
|
||||
button that copies the picture to the clipboard, and a link that opens it in its
|
||||
own tab. Escape closes it and puts the cursor back where it was.
|
||||
|
||||
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
|
||||
little else, and a photo off a phone is a JPEG — without the conversion the copy
|
||||
either fails or succeeds with something nothing will paste.
|
||||
|
||||
### Code blocks
|
||||
|
||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||
|
||||
@@ -11,6 +11,7 @@ the app's icon.
|
||||
| --- | --- |
|
||||
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
|
||||
| Somebody shared a note with you | Once, when it happens |
|
||||
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
|
||||
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
|
||||
| Somebody gave you a task | Only on the change, and only to the person who got it |
|
||||
| Somebody commented | |
|
||||
|
||||
+12
-1
@@ -2379,8 +2379,19 @@ paths:
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
delete:
|
||||
tags: [projects]
|
||||
summary: Remove somebody from a project. Its owner only.
|
||||
summary: Remove somebody from a project — its owner, or yourself.
|
||||
description: |
|
||||
Two things through one route: the owner taking somebody off, and anybody
|
||||
taking themselves off. The second is what makes a share something you can
|
||||
undo. You are let into a project without being asked, so a way out that
|
||||
does not go through asking its owner is the other half of that.
|
||||
|
||||
The owner cannot leave their own project. That is a transfer of ownership,
|
||||
not an exit, and there is no transfer yet.
|
||||
|
||||
Pointing at somebody else's membership without owning the project answers
|
||||
404 rather than 403: saying "you may not" confirms the membership exists.
|
||||
|
||||
Their tasks are unassigned rather than left pointing at somebody who can
|
||||
no longer see the project — which is why changing a role is a PATCH and
|
||||
not a remove-and-reinvite.
|
||||
|
||||
@@ -24,6 +24,16 @@ becomes a link that creates their account and their membership at once. With no
|
||||
mail server configured the link is shown in the panel instead of being sent, which
|
||||
is the correct behaviour on a one-person instance rather than an error.
|
||||
|
||||
**You are told, and you can leave.** Being added to a project is not something
|
||||
that happens to you quietly: the bell says who shared what, and the entry takes
|
||||
you to the project. In the share panel, anybody who is not the owner has a *Leave
|
||||
the project* button. That is deliberate rather than an approval step — an
|
||||
invitation waiting on a yes means the person who shared thinks it is done while
|
||||
the work sits still. What was wrong was the other three-quarters of it: you were
|
||||
added without being asked, without being told, and with no way out except asking
|
||||
the owner. The owner cannot leave their own project; that is a transfer of
|
||||
ownership, and there is no transfer yet.
|
||||
|
||||
An invitation that has not been taken up stays listed under the members, greyed,
|
||||
with a × to withdraw it. Without that it is invisible — the person has no account,
|
||||
so they are neither a member nor somebody you can pick — and the usual next move
|
||||
|
||||
@@ -140,6 +140,30 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
|
||||
})
|
||||
}
|
||||
|
||||
// notifyProjectShared tells somebody that a project is now theirs to work in.
|
||||
//
|
||||
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
|
||||
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
|
||||
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
|
||||
// opgaver til dig i det.
|
||||
//
|
||||
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
|
||||
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
|
||||
// handling — de skal ikke have besked om det, de lige selv gjorde.
|
||||
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
|
||||
if project == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
if actor.ID == userID {
|
||||
return
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
|
||||
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||
// it — folded into one line per note while it is unread.
|
||||
//
|
||||
|
||||
@@ -624,6 +624,9 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
||||
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
|
||||
// de kiggede — uden at noget sagde hvornår eller fra hvem.
|
||||
s.notifyProjectShared(r, project, person.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"added": true,
|
||||
// Personen sendes med retur, fordi fladen kan have delt med en adresse
|
||||
@@ -707,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
|
||||
// anybody removing themselves.
|
||||
//
|
||||
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
|
||||
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
|
||||
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
|
||||
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
|
||||
//
|
||||
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
|
||||
// overdragelse, og den findes ikke endnu.
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
me := userFrom(r.Context())
|
||||
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
|
||||
// persons medlemskab, bekræfter at medlemskabet findes.
|
||||
if me.ID != owner && userID != me.ID {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"the owner cannot be removed from their own project")
|
||||
|
||||
@@ -627,3 +627,98 @@ func hasPerson(raw any, id string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
|
||||
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
|
||||
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
anden := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
andenUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": andenUser.ID, "role": "editor"})
|
||||
|
||||
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
|
||||
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
|
||||
var shared map[string]any
|
||||
for _, raw := range inbox["notifications"].([]any) {
|
||||
if n := raw.(map[string]any); n["kind"] == "project.shared" {
|
||||
shared = n
|
||||
}
|
||||
}
|
||||
if shared == nil {
|
||||
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
|
||||
}
|
||||
if shared["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
|
||||
shared["project_id"], projectID)
|
||||
}
|
||||
|
||||
// Udgangen.
|
||||
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// En udgang for en selv er ikke en dør til de andres medlemskaber.
|
||||
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
en := ts.newUser(t, "[email protected]", "En")
|
||||
ts.newUser(t, "[email protected]", "To")
|
||||
|
||||
enUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
for _, u := range []string{enUser.ID, toUser.ID} {
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": u, "role": "editor"})
|
||||
}
|
||||
|
||||
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
|
||||
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if len(members["members"].([]any)) != 3 {
|
||||
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
|
||||
}
|
||||
}
|
||||
|
||||
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
|
||||
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
|
||||
resp, _ := ts.do(t, "DELETE",
|
||||
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +493,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/", s.handleGetProject)
|
||||
r.Get("/sections", s.handleListSections)
|
||||
r.Get("/members", s.handleListMembers)
|
||||
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
r.Get("/activity", s.handleActivity)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -508,7 +511,6 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/invites", s.handleInvite)
|
||||
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4597,5 +4597,155 @@ test('et projekt kan deles med en, der allerede har en konto', async ({ browser,
|
||||
// spring tilbage til det første projekt, og delingspanelet står åbent på tværs
|
||||
// af det spring — så klikket på "Del" lukkede det, jeg lige havde åbnet.
|
||||
|
||||
// Og så den anden vej: hvad Gartner selv oplever. Man bliver lukket ind i et
|
||||
// projekt uden at blive spurgt, så man skal i det mindste få det at vide og
|
||||
// kunne gå ud igen uden at bede ejeren om lov.
|
||||
const theirs = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const gartner = await theirs.newPage();
|
||||
const gartnerTrouble = watchForTrouble(gartner);
|
||||
await gartner.goto('/');
|
||||
await gartner.getByLabel('E-mail').fill('[email protected]');
|
||||
await gartner.getByLabel('Adgangskode').fill('et langt kodeord til test');
|
||||
await gartner.getByRole('button', { name: 'Log ind' }).click();
|
||||
await expect(gartner.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
await gartner.getByRole('button', { name: /Beskeder/ }).click();
|
||||
const bell = gartner.getByRole('dialog', { name: 'Beskeder' });
|
||||
await expect(bell.getByText('delte et projekt med dig')).toBeVisible();
|
||||
|
||||
// Beskeden fører hen til projektet. En besked, der ikke kan følges, er en
|
||||
// oplysning om noget, man selv skal gå ud og finde.
|
||||
await bell.locator('.row').filter({ hasText: 'delte et projekt med dig' }).click();
|
||||
await expect(gartner.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
|
||||
|
||||
// Udgangen. Bekræftelsen siges ja til, ellers sker der intet.
|
||||
gartner.on('dialog', (d) => d.accept());
|
||||
await projectAction(gartner, /^Del/);
|
||||
await gartner.getByRole('button', { name: 'Forlad projektet' }).click();
|
||||
|
||||
// Væk fra sidebjælken, og ikke længere til at åbne.
|
||||
await expect(
|
||||
gartner.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Drivhuset/ })
|
||||
).toHaveCount(0);
|
||||
|
||||
// En 404 her er ikke uro: den er svaret på at bede om et projekt, man lige har
|
||||
// forladt, og den er hele pointen med at have forladt det.
|
||||
expect(gartnerTrouble.filter((t) => !t.includes('404'))).toEqual([]);
|
||||
await theirs.close();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En søgning i ⌘K efterlader resultaterne, den fandt.
|
||||
*
|
||||
* Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
|
||||
* åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
|
||||
* væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
|
||||
* lukket. Rapporteret sådan her: "jeg får kun præsenteret en popup liste med
|
||||
* resultater, men hvis jeg trykker retur, kommer jeg tilbage til den normale liste
|
||||
* — ikke resultater."
|
||||
*
|
||||
* Prøven laver sine egne noter med et ord, ingen anden note i basen har. Et ord
|
||||
* lånt fra en tidligere prøve ville gøre den her afhængig af rækkefølgen, og den
|
||||
* kobling har allerede narret mig én gang i den her fil.
|
||||
*/
|
||||
test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
|
||||
// Gem-knap at huske.
|
||||
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill(`${title}\n\nnoget om den`);
|
||||
await body.blur();
|
||||
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /Søg/ }).click();
|
||||
const palette = page.getByRole('dialog');
|
||||
await palette.getByRole('textbox').fill('Fyrreskov');
|
||||
await expect(palette.locator('li button', { hasText: 'Fyrreskov nord' })).toBeVisible();
|
||||
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
|
||||
|
||||
// Noten er åben …
|
||||
await expect(page).toHaveURL(/\/noter\?note=[^&]+&q=Fyrreskov/);
|
||||
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
|
||||
|
||||
// … og listen ved siden af den er de andre træffere, ikke den normale liste.
|
||||
await expect(page.getByPlaceholder('Søg i noter')).toHaveValue('Fyrreskov');
|
||||
const list = page.locator(".list");
|
||||
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
|
||||
await expect(list.getByText('Egeskov')).toHaveCount(0);
|
||||
|
||||
// Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente
|
||||
// en søgning tilbage, man selv havde ryddet væk.
|
||||
await page.getByPlaceholder('Søg i noter').fill('');
|
||||
await expect(page).not.toHaveURL(/[?&]q=/);
|
||||
await expect(list.getByText('Egeskov')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
|
||||
*
|
||||
* I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
|
||||
* er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
|
||||
* ikke ser ud som et. Der var ingen vej fra det til billedet selv.
|
||||
*
|
||||
* Prøven indsætter billedet ad den vej, folk gør det: en rigtig paste-hændelse med
|
||||
* en fil i udklipsholderen. Et billede lagt ind ved at skrive markdown ville springe
|
||||
* hele vedhæftningen over og dermed netop den kode, det her handler om.
|
||||
*/
|
||||
test('et indsat billede kan åbnes stort og kopieres', async ({ page, context }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
// Kopiering til udklipsholderen kræver lov. Uden den fejler knappen, og prøven
|
||||
// ville måle en browserindstilling frem for programmet.
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill('Billednote');
|
||||
|
||||
// En rigtig 1×1 JPEG, og med vilje ikke en PNG: udklipsholderen tager kun PNG,
|
||||
// så et foto skal tegnes om undervejs — og et foto fra en telefon ER en JPEG.
|
||||
// Med en PNG her ville prøven køre forbi netop den kode, den skal vogte.
|
||||
await body.evaluate((el) => {
|
||||
const jpeg =
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
|
||||
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
|
||||
'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
|
||||
const bytes = Uint8Array.from(atob(jpeg), (c) => c.charCodeAt(0));
|
||||
const file = new File([bytes], 'skud.jpg', { type: 'image/jpeg' });
|
||||
const data = new DataTransfer();
|
||||
data.items.add(file);
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
const image = page.locator('[aria-label="Notens tekst"] img');
|
||||
await expect(image).toBeVisible();
|
||||
|
||||
// Klikket åbner det, og ruden viser det samme billede.
|
||||
await image.click();
|
||||
const shot = page.getByRole('dialog', { name: 'Billede' });
|
||||
await expect(shot).toBeVisible();
|
||||
await expect(shot.locator('img')).toBeVisible();
|
||||
|
||||
// Kopiér-knappen skal gøre noget. En knap, der tier, er værre end ingen knap:
|
||||
// man indsætter noget andet og opdager det et helt andet sted.
|
||||
await shot.getByRole('button', { name: 'Kopiér billedet' }).click();
|
||||
await expect(shot.getByRole('button', { name: 'Kopieret' })).toBeVisible();
|
||||
await expect(shot.getByText('Kunne ikke kopiere')).toHaveCount(0);
|
||||
|
||||
// Escape lukker, og teksten har fokus igen — ellers står fokus på en knap, der
|
||||
// ikke findes mere, og den næste tast går ingen steder.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(shot).toHaveCount(0);
|
||||
await expect(body).toBeFocused();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -125,7 +125,13 @@
|
||||
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
|
||||
// op, den ER stedet.
|
||||
if (item.kind === 'setting') return goto(item.id);
|
||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
|
||||
// liste står uændret — og de andre træffere var kun at se i den popup, man
|
||||
// lige lukkede.
|
||||
if (item.kind === 'note') {
|
||||
const q = query.trim();
|
||||
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
|
||||
}
|
||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -640,9 +640,69 @@
|
||||
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||
return;
|
||||
}
|
||||
// Et billede i en note er et billede, man har indsat for at kunne se det. I
|
||||
// arket er det skaleret ned til spaltens bredde — et foto fra en telefon er
|
||||
// fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede,
|
||||
// der ikke ser ud som et.
|
||||
const image = event.target.closest?.('img');
|
||||
if (image) {
|
||||
event.preventDefault();
|
||||
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||
copied = false;
|
||||
copyError = '';
|
||||
return;
|
||||
}
|
||||
colourCode();
|
||||
}
|
||||
|
||||
// --- billedet i fuld størrelse ------------------------------------------------
|
||||
|
||||
let showing = $state(null);
|
||||
let copied = $state(false);
|
||||
let copyError = $state('');
|
||||
|
||||
function closeImage() {
|
||||
showing = null;
|
||||
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
||||
// findes længere, og den næste tast går ingen steder.
|
||||
editor?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Billedet som PNG, uanset hvad det kom som.
|
||||
*
|
||||
* Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon
|
||||
* er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers
|
||||
* lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte.
|
||||
*/
|
||||
async function asPNG(src) {
|
||||
const blob = await (await fetch(src)).blob();
|
||||
if (blob.type === 'image/png') return blob;
|
||||
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
}
|
||||
|
||||
async function copyImage() {
|
||||
copyError = '';
|
||||
try {
|
||||
// Løftet gives videre frem for at blive ventet af først. Safari kræver, at
|
||||
// clipboard.write kaldes i selve klikket, og en hentning imellem gør
|
||||
// klikket til fortid — et ClipboardItem må gerne indeholde et løfte.
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch (e) {
|
||||
// Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre
|
||||
// end ingen knap: man indsætter noget andet og opdager det et andet sted.
|
||||
copyError = t('notes.copyFailed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole thing. */
|
||||
function accept(item) {
|
||||
const partial = partialTag();
|
||||
@@ -1068,7 +1128,102 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
|
||||
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
|
||||
af netop det ark, det skal lægge sig over. -->
|
||||
{#if showing}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
||||
<img src={showing.src} alt={showing.alt} />
|
||||
<div class="tools">
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
</div>
|
||||
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (showing && e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
closeImage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
||||
står alene — det er hele grunden til at åbne det. */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgb(0 0 0 / 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s4);
|
||||
}
|
||||
|
||||
.shot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
|
||||
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
|
||||
passe sig ind i den. */
|
||||
.shot img {
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 8rem);
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tools button,
|
||||
.tools a {
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tools button:hover,
|
||||
.tools a:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.bad {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
|
||||
.page :global(img) {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
assigned: 'notif.assigned',
|
||||
'note.changed': 'notif.noteChanged',
|
||||
'note.shared': 'notif.noteShared',
|
||||
'project.shared': 'notif.projectShared',
|
||||
'note.mention': 'notif.mention',
|
||||
'daily.plan': 'notif.dailyPlan',
|
||||
'update.available': 'notif.update',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const da = {
|
||||
'notif.assigned': '{name} gav dig en opgave',
|
||||
'notif.noteChanged': '{name} rettede en note',
|
||||
'notif.noteShared': '{name} delte en note med dig',
|
||||
'notif.projectShared': '{name} delte et projekt med dig',
|
||||
'notif.mention': '{name} nævnte dig i en note',
|
||||
'notif.dailyPlan': 'Dagens plan',
|
||||
'notif.update': 'Der er kommet en ny version',
|
||||
@@ -172,6 +173,10 @@ export const da = {
|
||||
'notes.copyCode': 'Kopiér',
|
||||
'notes.copied': 'Kopieret',
|
||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||
'notes.imageTitle': 'Billede',
|
||||
'notes.imageCopy': 'Kopiér billedet',
|
||||
'notes.imageCopied': 'Kopieret',
|
||||
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.code': 'Kode',
|
||||
'notes.showSource': 'Vis kilden (Markdown)',
|
||||
@@ -909,6 +914,8 @@ export const da = {
|
||||
'project.canView': 'Kan kun se',
|
||||
'project.invite': 'Inviter',
|
||||
'project.pickPerson': 'Vælg en person…',
|
||||
'project.leave': 'Forlad projektet',
|
||||
'project.leaveSure': 'Forlad {name}? Du mister adgangen, og kun ejeren kan lukke dig ind igen.',
|
||||
'project.roleForPicked': 'Rolle for den valgte',
|
||||
'project.shareWith': 'Del',
|
||||
'project.orByEmail': 'Eller en, der ikke er her endnu:',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const en = {
|
||||
'notif.assigned': '{name} gave you a task',
|
||||
'notif.noteChanged': '{name} edited a note',
|
||||
'notif.noteShared': '{name} shared a note with you',
|
||||
'notif.projectShared': '{name} shared a project with you',
|
||||
'notif.mention': '{name} mentioned you in a note',
|
||||
'notif.dailyPlan': "Today's plan",
|
||||
'notif.update': 'A new version is out',
|
||||
@@ -169,6 +170,10 @@ export const en = {
|
||||
'notes.copyCode': 'Copy',
|
||||
'notes.copied': 'Copied',
|
||||
'notes.copyFailed': 'Could not copy',
|
||||
'notes.imageTitle': 'Image',
|
||||
'notes.imageCopy': 'Copy the image',
|
||||
'notes.imageCopied': 'Copied',
|
||||
'notes.imageOpen': 'Open in a new tab',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.code': 'Code',
|
||||
'notes.showSource': 'Show the source (Markdown)',
|
||||
@@ -903,6 +908,8 @@ export const en = {
|
||||
'project.canView': 'Can only view',
|
||||
'project.invite': 'Invite',
|
||||
'project.pickPerson': 'Pick somebody…',
|
||||
'project.leave': 'Leave the project',
|
||||
'project.leaveSure': 'Leave {name}? You lose access, and only its owner can let you back in.',
|
||||
'project.roleForPicked': 'Role for the person picked',
|
||||
'project.shareWith': 'Share',
|
||||
'project.orByEmail': 'Or somebody who is not here yet:',
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { goto, replaceState } from '$app/navigation';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
@@ -483,6 +484,49 @@
|
||||
load(query);
|
||||
});
|
||||
|
||||
/**
|
||||
* En søgning, man kommer med, og en søgning, man kan gå videre med.
|
||||
*
|
||||
* ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede
|
||||
* den noten på en side, hvis liste stod uændret. Så var de andre træffere væk,
|
||||
* og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg
|
||||
* får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg
|
||||
* tilbage til den normale liste."
|
||||
*
|
||||
* Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre
|
||||
* træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og
|
||||
* deles — og frem for alt ikke lyver, når man har tømt feltet.
|
||||
*
|
||||
* `settled` er det, de to retninger er blevet enige om — og den læses med
|
||||
* `untrack`, hvilket er hele forskellen på at virke og ikke.
|
||||
*
|
||||
* Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og
|
||||
* `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor
|
||||
* den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og
|
||||
* skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen.
|
||||
* Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig
|
||||
* på den fælles markør.
|
||||
*/
|
||||
let settled = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const inURL = $page.url.searchParams.get('q') ?? '';
|
||||
if (inURL === untrack(() => settled)) return;
|
||||
settled = inURL;
|
||||
draftQuery = inURL;
|
||||
query = inURL;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const now = query;
|
||||
if (now === untrack(() => settled)) return;
|
||||
settled = now;
|
||||
const url = new URL(untrack(() => $page.url));
|
||||
if (now) url.searchParams.set('q', now);
|
||||
else url.searchParams.delete('q');
|
||||
replaceState(url, {});
|
||||
});
|
||||
|
||||
// Arriving from somewhere that names a note — a task's panel, a project's page —
|
||||
// opens it rather than dropping the person on a list to find it again.
|
||||
let asked = $derived($page.url.searchParams.get('note'));
|
||||
|
||||
@@ -551,6 +551,21 @@
|
||||
if (!inviteError) inviteEmail = '';
|
||||
}
|
||||
|
||||
// Udgangen. Man bliver lukket ind i et projekt uden at blive spurgt, så der skal
|
||||
// være en vej ud, der ikke går gennem at bede ejeren om det.
|
||||
async function leave() {
|
||||
if (!confirm(t('project.leaveSure', { name: project.name }))) return;
|
||||
try {
|
||||
await api.removeMember(project.id, app.user.id);
|
||||
// Væk fra en side, man ikke længere må se: bliver man stående, er det
|
||||
// næste, siden gør, at hente den og få 404.
|
||||
await goto('/');
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw(invite) {
|
||||
try {
|
||||
await api.withdrawInvite(project.id, invite.id);
|
||||
@@ -826,6 +841,12 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Kun for dem, der er lukket ind. Ejeren kan ikke gå fra sit eget
|
||||
projekt — det ville være en overdragelse, ikke en udgang. -->
|
||||
{#if !isOwner}
|
||||
<button class="leave" onclick={leave}>{t('project.leave')}</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<!-- Den, der allerede er her, vælges. Indtil nu kunne et projekt kun
|
||||
deles ved at stave en e-mailadresse, altså ved at kende adressen på
|
||||
@@ -1501,6 +1522,17 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* I fareskrift og alene: det er den eneste knap i panelet, der handler om en
|
||||
selv, og den skal ikke kunne forveksles med at fjerne en anden. */
|
||||
.leave {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
color: var(--danger);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.byemail-hint {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
|
||||
Reference in New Issue
Block a user