Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c151d6c98c | ||
|
|
bd5e86dd03 | ||
|
|
bb25b94f96 |
@@ -585,6 +585,15 @@ func inboxName(locale string) string {
|
|||||||
// sync that has been failing for a week is invisible otherwise. The background job
|
// sync that has been failing for a week is invisible otherwise. The background job
|
||||||
// writes a log line the next restart erases, and nobody is watching the one screen
|
// writes a log line the next restart erases, and nobody is watching the one screen
|
||||||
// that would have said so.
|
// that would have said so.
|
||||||
|
// StatusUpstreamRefused is what this server answers when a service it depends on
|
||||||
|
// says no. Not 502, though that is what it means: a proxy is entitled to replace
|
||||||
|
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
|
||||||
|
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
|
||||||
|
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
|
||||||
|
// "something went wrong". A 4xx passes through untouched, and the truth reaching
|
||||||
|
// the person who has to act on it beats the tidier status code.
|
||||||
|
const StatusUpstreamRefused = http.StatusUnprocessableEntity
|
||||||
|
|
||||||
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
|
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
|
||||||
s.log.Warn(what, "err", err)
|
s.log.Warn(what, "err", err)
|
||||||
|
|
||||||
@@ -593,12 +602,12 @@ func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what str
|
|||||||
userID = u.ID
|
userID = u.ID
|
||||||
}
|
}
|
||||||
s.db.RecordError(r.Context(), store.ServerError{
|
s.db.RecordError(r.Context(), store.ServerError{
|
||||||
Method: r.Method, Path: r.URL.Path, Status: http.StatusBadGateway,
|
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
|
||||||
What: what, Message: err.Error(), UserID: userID,
|
What: what, Message: err.Error(), UserID: userID,
|
||||||
RequestID: middleware.GetReqID(r.Context()),
|
RequestID: middleware.GetReqID(r.Context()),
|
||||||
})
|
})
|
||||||
|
|
||||||
writeError(w, http.StatusBadGateway, code, err.Error())
|
writeError(w, StatusUpstreamRefused, code, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||||
if resp.StatusCode != http.StatusBadGateway {
|
if resp.StatusCode != StatusUpstreamRefused {
|
||||||
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
||||||
resp.StatusCode)
|
resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
|||||||
if row["what"] != "sync gmail" {
|
if row["what"] != "sync gmail" {
|
||||||
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
||||||
}
|
}
|
||||||
if row["status"] != float64(http.StatusBadGateway) {
|
if row["status"] != float64(StatusUpstreamRefused) {
|
||||||
t.Errorf("status = %v, want 502", row["status"])
|
t.Errorf("status = %v, want 502", row["status"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
|||||||
t.Errorf("the sync took %s; the budget is not being applied",
|
t.Errorf("the sync took %s; the budget is not being applied",
|
||||||
took.Round(time.Second))
|
took.Round(time.Second))
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 500 && resp.StatusCode != http.StatusBadGateway {
|
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
|
||||||
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
|
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
|
||||||
}
|
}
|
||||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|||||||
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("ai split", "err", err)
|
s.log.Warn("ai split", "err", err)
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("ai summary", "err", err)
|
s.log.Warn("ai summary", "err", err)
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal,
|
writeError(w, StatusUpstreamRefused, CodeInternal,
|
||||||
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
|
|||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
|
|
||||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||||
if resp.StatusCode != http.StatusBadGateway {
|
if resp.StatusCode != StatusUpstreamRefused {
|
||||||
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
|
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {
|
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {
|
||||||
|
|||||||
+42
-22
@@ -302,26 +302,41 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
await expect(heading).toBeVisible();
|
await expect(heading).toBeVisible();
|
||||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||||
|
|
||||||
// The words in the heading must not sit on top of each other.
|
// A group's name must be readable in full, and must start where a project's
|
||||||
|
// name starts.
|
||||||
//
|
//
|
||||||
// Everything else here clicks by accessible name, which is exactly why this
|
// Both halves are here because both were once broken at the same time and for
|
||||||
// needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for
|
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
||||||
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
|
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
||||||
// Both buttons still worked, so every assertion in this file passed.
|
// sat before the name, pushing a group 22px further in than the projects it
|
||||||
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
|
// contains — so a group read as if it were filed under them. Every assertion
|
||||||
// Links as well as buttons: the group's name is a link to its page now, and
|
// in this file passed through both, because both buttons still worked.
|
||||||
// a measurement that skipped it would miss the widest thing in the row.
|
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
||||||
[...el.querySelectorAll('button, a')].map((b) => {
|
const link = el.querySelector('h2 a');
|
||||||
const r = b.getBoundingClientRect();
|
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
||||||
return { text: b.textContent.trim(), left: r.left, right: r.right };
|
// The last child of the link is the name itself; the dot is a span before it.
|
||||||
})
|
return {
|
||||||
);
|
clipped: link.scrollWidth > link.clientWidth + 1,
|
||||||
for (let i = 1; i < boxes.length; i++) {
|
left: name.left,
|
||||||
expect(
|
right: link.getBoundingClientRect().right,
|
||||||
boxes[i].left,
|
count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity
|
||||||
`"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet`
|
};
|
||||||
).toBeGreaterThanOrEqual(boxes[i - 1].right);
|
});
|
||||||
}
|
|
||||||
|
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
||||||
|
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
||||||
|
|
||||||
|
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
||||||
|
// mark, and it is the only thing in them that means the same on both sides.
|
||||||
|
// Comparing the group's inner link with the project's outer box reported the
|
||||||
|
// row's own padding as a misalignment.
|
||||||
|
const projectLeft = await sidebar
|
||||||
|
.getByRole('link', { name: 'Regnskab' })
|
||||||
|
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
||||||
|
expect(
|
||||||
|
Math.abs(shape.left - projectLeft),
|
||||||
|
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
||||||
|
).toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
||||||
// that heading is a drop target at all: with every project filed away there
|
// that heading is a drop target at all: with every project filed away there
|
||||||
@@ -348,7 +363,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
// Colour, which is chosen while renaming rather than from the row. Both are
|
// Colour, which is chosen while renaming rather than from the row. Both are
|
||||||
// edits to the same thing, and a heading carrying four controls crowded the
|
// edits to the same thing, and a heading carrying four controls crowded the
|
||||||
// name it exists to show.
|
// name it exists to show.
|
||||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
// Double-click on the row opens it; the two words that used to sit on top of
|
||||||
|
// the name are gone from the sidebar entirely.
|
||||||
|
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
||||||
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
||||||
// Still open: picking a colour must not close the form under the pointer, which
|
// Still open: picking a colour must not close the form under the pointer, which
|
||||||
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
||||||
@@ -392,7 +409,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
|
|
||||||
// Renaming writes one row rather than one per project, which is the reason a
|
// Renaming writes one row rather than one per project, which is the reason a
|
||||||
// group is a table and not a string repeated on every project.
|
// group is a table and not a string repeated on every project.
|
||||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
||||||
const name = sidebar.getByLabel('Gruppens navn');
|
const name = sidebar.getByLabel('Gruppens navn');
|
||||||
await name.fill('Kontoret');
|
await name.fill('Kontoret');
|
||||||
await name.press('Enter');
|
await name.press('Enter');
|
||||||
@@ -401,8 +418,11 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
// Deleting the heading must not take the project filed under it. It is
|
// Deleting the heading must not take the project filed under it. It is
|
||||||
// ungrouped here, which is the case that would look identical either way if
|
// ungrouped here, which is the case that would look identical either way if
|
||||||
// the assertion were only "the group is gone".
|
// the assertion were only "the group is gone".
|
||||||
|
// Deleting is on the group's own page now, where the projects it will not take
|
||||||
|
// with it are on the screen above the button.
|
||||||
|
await sidebar.getByRole('link', { name: 'Kontoret', exact: true }).click();
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
await sidebar.getByRole('button', { name: 'Slet' }).click();
|
await page.getByRole('button', { name: 'Slet' }).click();
|
||||||
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
||||||
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -105,17 +105,18 @@
|
|||||||
await app.renameGroup(group.id, name);
|
await app.renameGroup(group.id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeGroup(group) {
|
/** Double-click, or F2 with the row focused. The colour is chosen in the same
|
||||||
const count = inGroup(group.id).length;
|
* form, because it is the same edit to the same thing. */
|
||||||
// Says what happens to the projects, because "slet gruppen" reads like it
|
/** Cleared by a second click, which means the double was a rename. */
|
||||||
// might take them with it. It does not: only the heading goes.
|
let navTimer;
|
||||||
const kept =
|
|
||||||
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
function startRename(group) {
|
||||||
const question = t('sidebar.deleteGroup', { name: group.name });
|
clearTimeout(navTimer);
|
||||||
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
renamingGroup = group.id;
|
||||||
await app.deleteGroup(group.id);
|
groupName = group.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- resizing -------------------------------------------------------------------
|
// --- resizing -------------------------------------------------------------------
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false);
|
||||||
@@ -547,6 +548,13 @@
|
|||||||
overId = null;
|
overId = null;
|
||||||
}}
|
}}
|
||||||
ondrop={(e) => onGroupDrop(e, group)}
|
ondrop={(e) => onGroupDrop(e, group)}
|
||||||
|
ondblclick={() => startRename(group)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
e.preventDefault();
|
||||||
|
startRename(group);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if renamingGroup === group.id}
|
{#if renamingGroup === group.id}
|
||||||
<!-- Renaming is where the colour is chosen too.
|
<!-- Renaming is where the colour is chosen too.
|
||||||
@@ -618,7 +626,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h2 class="fold">
|
<h2 class="fold">
|
||||||
<a href="/gruppe/{group.id}" onclick={onnavigate}>
|
<!-- Navigation waits two tenths of a second for a second click. A
|
||||||
|
link cannot know a double is coming without pausing, and the
|
||||||
|
alternative was worse than the pause: the first click opened the
|
||||||
|
page while the rename form was appearing, and the colour swatches
|
||||||
|
moved out from under the pointer. Two tenths is under the double
|
||||||
|
click threshold and over the threshold of noticing. -->
|
||||||
|
<a
|
||||||
|
href="/gruppe/{group.id}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(navTimer);
|
||||||
|
if (e.detail > 1) return;
|
||||||
|
navTimer = setTimeout(() => {
|
||||||
|
onnavigate();
|
||||||
|
goto(`/gruppe/${group.id}`);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="dot group-dot"
|
class="dot group-dot"
|
||||||
style="background: {colorVar(group.color)}"
|
style="background: {colorVar(group.color)}"
|
||||||
@@ -628,14 +653,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
<span class="count">{inside.length}</span>
|
<span class="count">{inside.length}</span>
|
||||||
<button
|
|
||||||
class="group-action"
|
|
||||||
onclick={() => {
|
|
||||||
renamingGroup = group.id;
|
|
||||||
groupName = group.name;
|
|
||||||
}}>{t('sidebar.rename')}</button
|
|
||||||
>
|
|
||||||
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -939,14 +956,30 @@
|
|||||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
/* The chevron on its own, so the name beside it can be a link to the group's
|
||||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
||||||
you miss. */
|
you miss. */
|
||||||
|
/* The chevron sits on top of the group's dot rather than before it. In the flow
|
||||||
|
it pushed the name 22px further in than a project's, so groups read as if
|
||||||
|
they were filed under the projects above them — the opposite of what they
|
||||||
|
are. Out of the flow, a group's name starts exactly where a project's does.
|
||||||
|
The dot is the resting state; the chevron takes over on hover or focus, and
|
||||||
|
stays put while the group is folded, so a folded group still shows the way
|
||||||
|
back open. */
|
||||||
.chevron-button {
|
.chevron-button {
|
||||||
flex: none;
|
position: absolute;
|
||||||
|
left: calc(var(--s2) - 5px);
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-head:hover .chevron-button,
|
||||||
|
.chevron-button:focus-visible,
|
||||||
|
.chevron-button[aria-expanded='false'] {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron-button:hover {
|
.chevron-button:hover {
|
||||||
@@ -997,30 +1030,34 @@
|
|||||||
|
|
||||||
/* Hidden until the heading is hovered, like the section actions on a project
|
/* Hidden until the heading is hovered, like the section actions on a project
|
||||||
page: two buttons permanently beside a label stop it reading as a label. */
|
page: two buttons permanently beside a label stop it reading as a label. */
|
||||||
.group-action {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--ink-faint);
|
|
||||||
padding: 0 var(--s1);
|
|
||||||
flex: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--fast) var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-head:hover .group-action,
|
|
||||||
.group-action:focus-visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-action:hover {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-action.remove:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-head .count {
|
.folder-head .count {
|
||||||
margin-left: 0;
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without min-width a flex item refuses to shrink below its content, so a long
|
||||||
|
group name pushed the count off the row instead of ending in an ellipsis. */
|
||||||
|
.folder-head h2 {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No left padding of its own: the row already has the same padding as a project
|
||||||
|
row, and the anchor's would be added on top — which is where the last eight
|
||||||
|
pixels of the old indent were hiding. Vertical padding stays, so the heading
|
||||||
|
keeps its height. */
|
||||||
|
.folder-head h2 a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--s2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-head h2 a :global(*:last-child),
|
||||||
|
.folder-head h2 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The group's own mark, between the chevron and the name — inside the fold
|
/* The group's own mark, between the chevron and the name — inside the fold
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* projects answers is what is left.
|
* projects answers is what is left.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, projectName } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -29,6 +30,18 @@
|
|||||||
load(id);
|
load(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Says what happens to the projects, because "delete the group" reads like it
|
||||||
|
* might take them with it. It does not: only the heading goes. */
|
||||||
|
async function removeGroup() {
|
||||||
|
const count = projects.length;
|
||||||
|
const kept =
|
||||||
|
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
||||||
|
const question = t('sidebar.deleteGroup', { name: group.name });
|
||||||
|
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
||||||
|
await app.deleteGroup(group.id);
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
|
||||||
async function load(groupId) {
|
async function load(groupId) {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
status = 'loading';
|
status = 'loading';
|
||||||
@@ -208,6 +221,14 @@
|
|||||||
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
|
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Deleting lives here rather than in the sidebar, where two words sat on
|
||||||
|
top of the group's name and where you could press them without having
|
||||||
|
seen what the group holds. Here the projects are on the screen above
|
||||||
|
it, which is the whole argument for the button being here. -->
|
||||||
|
<section class="danger">
|
||||||
|
<button class="remove" onclick={removeGroup}>{t('sidebar.delete')}</button>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,6 +251,22 @@
|
|||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
margin-top: var(--s4);
|
||||||
|
padding-top: var(--s4);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger .remove {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: var(--s2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger .remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user