diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d0cdcc1e..f205a30a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -16,8 +16,9 @@ browser with zero runtime overhead.
Forgejo-specific UI components are colocated under `components/`. Each component directory keeps its template fragments,
CSS, browser controller, and focused tests together. `components/common` contains controls that do not know about page
-routes; `components/repository` contains repository filtering, sorting, and result rendering. Page composition remains
-in `templates/org`, `templates/user`, and `templates/repo`, while page-only CSS lives under `styles/pages`.
+routes; `components/project` contains the project list shared by repository, organization and user pages;
+`components/repository` contains repository filtering, sorting, and result rendering. Page composition remains in
+`templates/org`, `templates/user`, and `templates/repo`, while page-only CSS lives under `styles/pages`.
The bundle step assembles this source tree into `dist/forgejo/templates` and `dist/forgejo/public/assets`, which are the
installable Forgejo paths. The local preview mounts that assembled tree, so the same output is exercised during
diff --git a/components/common/dropdown/dropdown.css b/components/common/dropdown/dropdown.css
index 0669cac8..690b9b50 100644
--- a/components/common/dropdown/dropdown.css
+++ b/components/common/dropdown/dropdown.css
@@ -113,6 +113,16 @@
color: var(--color-text);
}
+/* Destructive actions such as Delete, red like GitHub's danger menu items. */
+.theme-dropdown-panel > .theme-dropdown-action.theme-dropdown-danger,
+.theme-dropdown-panel > .theme-dropdown-action.theme-dropdown-danger > .svg {
+ color: var(--github-fgColor-danger, var(--color-red));
+}
+
+.theme-dropdown-panel > .theme-dropdown-action.theme-dropdown-danger:hover {
+ background: var(--github-control-danger-bgColor-hover, color-mix(in srgb, var(--color-red) 15%, transparent));
+}
+
.theme-dropdown-action:disabled {
color: var(--color-text-light-1);
cursor: default;
diff --git a/components/project/list/actions.tmpl b/components/project/list/actions.tmpl
new file mode 100644
index 00000000..a3f2d3c0
--- /dev/null
+++ b/components/project/list/actions.tmpl
@@ -0,0 +1,7 @@
+{{svg "octicon-pencil" 16}}{{ctx.Locale.Tr "repo.issues.label_edit"}}
+{{if .IsClosed}}
+ {{svg "octicon-issue-reopened" 16}}{{ctx.Locale.Tr "repo.projects.open"}}
+{{else}}
+ {{svg "octicon-issue-closed" 16}}{{ctx.Locale.Tr "repo.projects.close"}}
+{{end}}
+{{svg "octicon-trash" 16}}{{ctx.Locale.Tr "repo.issues.label_delete"}}
diff --git a/components/project/list/list.css b/components/project/list/list.css
new file mode 100644
index 00000000..ac1f6a8b
--- /dev/null
+++ b/components/project/list/list.css
@@ -0,0 +1,168 @@
+/* GitHub's project list: a toolbar, then a box whose header holds the state tabs and Sort, with a row per project. */
+.theme-project-list-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.theme-project-list > .theme-list-heading {
+ margin-bottom: 16px;
+}
+
+.theme-project-list-search {
+ flex: 1;
+ min-width: 0;
+}
+
+.theme-project-list-search .theme-search-field {
+ width: 100%;
+}
+
+.theme-project-list-box {
+ border: 1px solid var(--color-light-border);
+ border-radius: var(--border-radius);
+}
+
+.theme-project-list-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 47px;
+ padding: 0 8px;
+ border-bottom: 1px solid var(--color-light-border);
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+ background: var(--color-box-header);
+}
+
+.theme-project-list-states {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.theme-project-list-states > a {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 8px;
+ border-radius: var(--border-radius);
+ color: var(--color-text-light-1);
+ font-size: 14px;
+ line-height: 30px;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.theme-project-list-states > a:hover {
+ color: var(--color-text);
+ background: var(--github-control-transparent-bgColor-hover);
+ text-decoration: none;
+}
+
+.theme-project-list-states > a[aria-current] {
+ color: var(--color-text);
+ font-weight: 600;
+}
+
+.theme-project-list-states .theme-counter {
+ font-weight: 600;
+}
+
+.theme-project-list-header > .theme-dropdown > summary.theme-control {
+ font-weight: 400;
+}
+
+.theme-project-list-header .theme-dropdown-panel {
+ width: 240px;
+}
+
+/* GitHub's Sort menu marks the current order with a check and keeps the labels aligned. */
+.theme-project-list-check {
+ flex: none;
+ visibility: hidden;
+}
+
+.theme-dropdown-action[aria-current] > .theme-project-list-check {
+ visibility: visible;
+}
+
+.theme-project-list-rows {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.theme-project-list-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ min-height: 64px;
+ margin: 0 16px;
+ padding: 8px 0;
+ border-top: 1px solid var(--color-light-border);
+}
+
+.theme-project-list-row:first-child {
+ border-top: 0;
+}
+
+.theme-project-list-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.theme-project-list-main h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 24px;
+ overflow-wrap: anywhere;
+}
+
+.theme-project-list-main h3 > a {
+ color: var(--color-text);
+}
+
+.theme-project-list-main h3 > a:hover {
+ color: var(--color-primary);
+}
+
+.theme-project-list-meta,
+.theme-project-list-description {
+ margin: 0;
+ color: var(--color-text-light-1);
+ font-size: 12px;
+ line-height: 18px;
+}
+
+.theme-project-list-description.markup {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.theme-project-list-description.markup > * {
+ display: inline;
+ margin: 0;
+ font-size: inherit;
+}
+
+.theme-project-list-row > .theme-dropdown > summary.theme-control {
+ width: 32px;
+ padding: 0;
+ justify-content: center;
+ color: var(--color-text-light-1);
+}
+
+.theme-project-list-row > .theme-dropdown .theme-dropdown-panel {
+ width: 192px;
+}
+
+@media (max-width: 543.98px) {
+ .theme-project-list-row {
+ margin: 0 8px;
+ }
+}
diff --git a/components/project/list/list.tmpl b/components/project/list/list.tmpl
new file mode 100644
index 00000000..0a2f34db
--- /dev/null
+++ b/components/project/list/list.tmpl
@@ -0,0 +1,59 @@
+{{- /* GitHub's project list for repository, organization and user pages: a toolbar, then one box with the Open and Closed
+tabs and Sort in its header and a row per project. Heading puts GitHub's repository heading with the sidebar toggle above
+the search; without it, New project sits beside the search as on GitHub's profile pages. */ -}}
+{{$data := .Data}}
+{{$canWrite := and $data.CanWriteProjects (not $data.Repository.IsArchived)}}
+{{$newProject := ""}}
+{{if $canWrite}}{{$newProject = print $data.Link "/new"}}{{end}}
+
+ {{if .Heading}}
+
+ {{end}}
+
+
+
+
+ {{range $data.Projects}}
+
+
+
+
#{{.ID}} {{ctx.Locale.Tr "issues.updated" (DateUtils.TimeSince .UpdatedUnix)}}
+ {{if .Description}}
{{.RenderedContent}}
{{end}}
+
+ {{if $canWrite}}
+ {{template "components/common/dropdown/dropdown" dict "Kind" "actions" "Icon" "octicon-kebab-horizontal" "IconOnly" true "Quiet" true "Label" (ctx.Locale.Tr "profile.actions.tooltip") "Items" "components/project/list/actions" "Data" (dict "Context" $data.Context "Link" (.Link ctx) "IsClosed" .IsClosed)}}
+ {{end}}
+
+ {{else}}
+
+ {{svg "octicon-project" 24}}
+ {{ctx.Locale.Tr "search.no_results"}}
+
+ {{end}}
+
+
+ {{template "base/paginate" $data}}
+ {{if $canWrite}}
+
+
+
{{ctx.Locale.Tr "repo.projects.deletion_desc"}}
+ {{template "base/modal_actions_confirm" $data}}
+
+ {{end}}
+
diff --git a/components/project/list/sort.tmpl b/components/project/list/sort.tmpl
new file mode 100644
index 00000000..22edf956
--- /dev/null
+++ b/components/project/list/sort.tmpl
@@ -0,0 +1,5 @@
+{{$query := print "?state=" .State "&q=" .Keyword}}
+{{svg "octicon-check" 16 "theme-project-list-check"}}{{ctx.Locale.Tr "issues.search.sort.latest"}}
+{{svg "octicon-check" 16 "theme-project-list-check"}}{{ctx.Locale.Tr "issues.search.sort.oldest"}}
+{{svg "octicon-check" 16 "theme-project-list-check"}}{{ctx.Locale.Tr "issues.search.sort.recentupdate"}}
+{{svg "octicon-check" 16 "theme-project-list-check"}}{{ctx.Locale.Tr "issues.search.sort.leastupdate"}}
diff --git a/public/assets/js/project-board.js b/public/assets/js/project-board.js
index ef05099d..4727a73b 100644
--- a/public/assets/js/project-board.js
+++ b/public/assets/js/project-board.js
@@ -113,6 +113,71 @@
cardsObserver.observe(cards, { childList: true });
}
+ // Card menu: its panel is pinned to the viewport so the scrolling column does not clip it, and the card's own
+ // column is not offered as a target.
+ const placeCardMenu = menu => {
+ const summary = menu.querySelector("summary").getBoundingClientRect();
+ const panel = menu.querySelector(".theme-dropdown-panel");
+ const width = panel.offsetWidth;
+ const right = document.documentElement.clientWidth - 16;
+ panel.style.translate = "none";
+ panel.style.left = `${Math.max(16, Math.min(summary.left, right - width))}px`;
+ panel.style.top = `${summary.bottom + 4}px`;
+ panel.style.right = "auto";
+ };
+ board.addEventListener(
+ "toggle",
+ event => {
+ const menu = event.target;
+ if (!menu.matches(".github-project-card-meta > .theme-dropdown[open]")) return;
+ const column = menu.closest(".project-column")?.dataset.id;
+ for (const button of menu.querySelectorAll("[data-project-card-move]"))
+ button.disabled = button.dataset.projectCardMove === column;
+ placeCardMenu(menu);
+ },
+ true
+ );
+ const closeCardMenus = () => {
+ for (const menu of board.querySelectorAll(".github-project-card-meta > .theme-dropdown[open]")) menu.open = false;
+ };
+ board.addEventListener("scroll", event => event.target.closest?.(".theme-dropdown-panel") || closeCardMenus(), true);
+ window.addEventListener("resize", closeCardMenus);
+
+ // Move to top, bottom or another column, then save the target column's order the way Forgejo's drag and drop does.
+ board.addEventListener("click", async event => {
+ const button = event.target.closest("[data-project-card-move]");
+ if (!button || button.disabled) return;
+ const card = button.closest(".issue-card");
+ const target = button.dataset.projectCardMove;
+ const column =
+ target === "top" || target === "bottom"
+ ? card.closest(".project-column")
+ : board.querySelector(`.project-column[data-id="${CSS.escape(target)}"]`);
+ const source = card.closest(".project-column");
+ const cards = column?.querySelector(":scope > .cards");
+ if (!cards) return;
+ if (target === "top") cards.prepend(card);
+ else cards.append(card);
+ for (const changed of new Set([source, column])) {
+ const count = changed.querySelector(".project-column-issue-count");
+ if (count) count.textContent = String(changed.querySelectorAll(".issue-card[data-issue]").length);
+ }
+ const issues = [...cards.querySelectorAll(":scope > .issue-card[data-issue]")].map((item, sorting) => ({
+ issueID: Number(item.dataset.issue),
+ sorting,
+ }));
+ try {
+ const response = await fetch(`${column.dataset.url}/move`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Csrf-Token": window.config?.csrfToken ?? "" },
+ body: JSON.stringify({ issues }),
+ });
+ if (!response.ok) throw new Error(String(response.status));
+ } catch {
+ window.location.reload();
+ }
+ });
+
// Side panel: a card opens its issue next to the board, as on GitHub. The issue page loads in a frame and drops
// the site chrome there (see base/head_style).
const panel = document.querySelector(".github-project-panel");
@@ -205,6 +270,7 @@
board.addEventListener("click", event => {
const card = event.target.closest(".issue-card");
if (!card || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ if (event.target.closest(".theme-dropdown")) return;
// Labels, assignees, milestones and pull requests keep their own links.
const link = event.target.closest("a");
if (link && !link.matches(".github-project-card-title")) return;
diff --git a/scripts/assembleForgejo.ts b/scripts/assembleForgejo.ts
index d68ee062..6f9701e5 100644
--- a/scripts/assembleForgejo.ts
+++ b/scripts/assembleForgejo.ts
@@ -28,7 +28,7 @@ const copyTemplates = (source: string, target: string): void => {
};
copyTemplates(join(root, "components"), join(templates, "components"));
-for (const directory of ["common", "repository"]) {
+for (const directory of ["common", "project", "repository"]) {
const source = join(root, "components", directory);
for (const component of readdirSync(source, { withFileTypes: true })) {
if (!component.isDirectory()) continue;
diff --git a/styles/pages/profile-subpages.css b/styles/pages/profile-subpages.css
index 24ff0952..3c187be3 100644
--- a/styles/pages/profile-subpages.css
+++ b/styles/pages/profile-subpages.css
@@ -164,8 +164,7 @@
background: var(--color-hover);
}
-:is(.github-profile, .github-org) .profile-person-list,
-:is(.github-profile, .github-org) .profile-project-list {
+:is(.github-profile, .github-org) .profile-person-list {
margin: 0;
padding: 0;
list-style: none;
@@ -267,18 +266,6 @@
line-height: 20px;
}
-:is(.github-profile, .github-org) .profile-projects .button {
- min-height: 32px;
- margin: 0;
- padding: 5px 16px;
- font-size: 14px;
- line-height: 20px;
-}
-
-:is(.github-profile, .github-org) .profile-projects .button.primary {
- box-shadow: none;
-}
-
:is(.github-profile, .github-org) .profile-package-row {
border: 1px solid var(--color-secondary);
border-bottom: 0;
@@ -331,79 +318,6 @@
font-size: 14px;
}
-:is(.github-profile, .github-org) .profile-projects .list-header {
- margin: 0;
- padding: 16px;
- gap: 16px;
- border: 1px solid var(--color-secondary);
- border-radius: 6px 6px 0 0;
- background: var(--color-nav-bg);
-}
-
-:is(.github-profile, .github-org) .profile-project-list {
- border: 1px solid var(--color-secondary);
- border-top: 0;
- border-radius: 0 0 6px 6px;
-}
-
-:is(.github-profile, .github-org) .profile-project {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 16px;
- border-bottom: 1px solid var(--color-secondary);
-}
-
-:is(.github-profile, .github-org) .profile-project:last-child {
- border-bottom: 0;
-}
-
-:is(.github-profile, .github-org) .profile-project h3 {
- display: flex;
- align-items: center;
- gap: 8px;
- margin: 0;
- font-size: 16px;
- line-height: 24px;
-}
-
-:is(.github-profile, .github-org) .profile-project h3 > .svg {
- flex: none;
- color: var(--color-success);
-}
-
-:is(.github-profile, .github-org) .profile-project .content {
- order: 1;
- color: var(--color-text-light-1);
- font-size: 14px;
- line-height: 21px;
-}
-
-:is(.github-profile, .github-org) .profile-project .content > :last-child {
- margin-bottom: 0;
-}
-
-:is(.github-profile, .github-org) .profile-project .milestone-toolbar {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- gap: 8px 16px;
- order: 2;
- color: var(--color-text-light-1);
- font-size: 12px;
- line-height: 18px;
-}
-
-:is(.github-profile, .github-org) .profile-project .milestone-toolbar .group {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
-}
-
-:is(.github-profile, .github-org) .profile-project-list > li > .profile-empty {
- border: 0;
-}
-
:is(.github-profile, .github-org) .profile-federation #activity-feed {
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
@@ -443,73 +357,16 @@
:is(.github-profile, .github-org) .profile-search-controls .theme-select {
width: 100%;
}
-
- :is(.github-profile, .github-org) .profile-projects .list-header {
- flex-wrap: wrap;
- padding: 12px;
- }
-
- :is(.github-profile, .github-org) .profile-projects .list-header-search {
- flex-basis: 100%;
- }
}
:is(.github-profile, .github-org) .profile-code input[type="search"]:focus {
border-color: var(--github-borderColor-accent-emphasis);
}
-:is(.github-profile, .github-org) .profile-packages .flex-item-title > a,
-:is(.github-profile, .github-org) .profile-project .milestone-header h3 > a {
+:is(.github-profile, .github-org) .profile-packages .flex-item-title > a {
color: var(--color-primary);
}
-:is(.github-profile, .github-org) .profile-project .milestone-header h3 > .svg {
- color: var(--color-green);
-}
-
-:is(.github-profile, .github-org) .profile-project .milestone-toolbar a {
- font-size: 12px;
-}
-
-:is(.github-profile, .github-org) .profile-projects .primary.button {
- height: 32px;
- min-height: 32px;
- box-shadow: none;
-}
-
-:is(.github-profile, .github-org) .profile-project-state {
- display: inline-flex;
- min-width: 0;
- border: 1px solid var(--color-secondary);
- border-radius: var(--border-radius);
- overflow: hidden;
-}
-
-:is(.github-profile, .github-org) .profile-project-state a {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- min-height: 30px;
- padding: 5px 12px;
- color: var(--color-text-light-1);
- font-size: 14px;
- line-height: 20px;
-}
-
-:is(.github-profile, .github-org) .profile-project-state a.active {
- background: var(--color-active);
- color: var(--color-text);
- font-weight: 600;
-}
-
-:is(.github-profile, .github-org) .profile-project-state a + a {
- border-left: 1px solid var(--color-secondary);
-}
-
-:is(.github-profile, .github-org) .profile-project.is-closed .milestone-header h3 > .svg {
- color: var(--color-text-light-1);
-}
-
:is(.github-profile, .github-org) .profile-subpage-title {
overflow-wrap: anywhere;
}
diff --git a/styles/pages/projects.css b/styles/pages/projects.css
index bb2e3cd9..6cddccb2 100644
--- a/styles/pages/projects.css
+++ b/styles/pages/projects.css
@@ -137,8 +137,8 @@
flex-shrink: 0;
gap: 8px;
justify-content: flex-start;
- min-height: 52px;
- padding: 12px 8px 8px 16px;
+ min-height: 44px;
+ padding: 8px 8px 4px 16px;
}
/* GitHub's default statuses and their colours, for columns without a colour of their own. */
#project-board .project-column[data-column-title="backlog" i] {
@@ -206,7 +206,7 @@
/* Cards */
#project-board .issue-card {
- background: var(--color-box-header);
+ background: var(--color-box-body);
border: 1px solid var(--color-light-border);
border-radius: 6px;
box-shadow: none;
@@ -263,7 +263,7 @@
}
.github-project-card-assignees img {
border-radius: 50%;
- box-shadow: 0 0 0 1px var(--color-box-header);
+ box-shadow: 0 0 0 1px var(--color-box-body);
display: block;
height: 20px;
width: 20px;
@@ -272,9 +272,44 @@
color: var(--color-text);
display: block;
font-size: 14px;
- line-height: 20px;
+ line-height: 1.3;
margin-top: 2px;
}
+/* GitHub's card menu sits after the reference and shows while the card is hovered, focused or the menu is open. The
+ board script pins its panel to the viewport, outside the scrolling column. */
+@media (hover: hover) {
+ .github-project-card-meta > .theme-dropdown {
+ opacity: 0;
+ }
+}
+#project-board .issue-card:is(:hover, :focus-within) .github-project-card-meta > .theme-dropdown,
+.github-project-card-meta > .theme-dropdown[open] {
+ opacity: 1;
+}
+.github-project-card-meta > .theme-dropdown > summary.theme-control.theme-control-small {
+ color: var(--color-text-light-2);
+ height: 20px;
+ min-height: 20px;
+ width: 24px;
+}
+.github-project-card-meta .theme-dropdown-panel {
+ position: fixed;
+ width: 240px;
+}
+.github-project-card-meta .theme-dropdown-group {
+ color: var(--color-text-light-2);
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 18px;
+ padding: 8px 16px 4px;
+}
+.github-project-card-meta .theme-dropdown-action > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
#project-board .github-project-card-title:hover {
color: var(--color-primary);
text-decoration: underline;
diff --git a/templates/base/component_assets.tmpl b/templates/base/component_assets.tmpl
index 8361c9e4..d408c645 100644
--- a/templates/base/component_assets.tmpl
+++ b/templates/base/component_assets.tmpl
@@ -15,6 +15,7 @@
+
diff --git a/templates/org/subpage_nav.tmpl b/templates/org/subpage_nav.tmpl
index 21025da9..578ca6f2 100644
--- a/templates/org/subpage_nav.tmpl
+++ b/templates/org/subpage_nav.tmpl
@@ -5,8 +5,8 @@
{{if .IsOrganizationMember}}{{svg "octicon-people" 16}}{{ctx.Locale.Tr "org.teams"}} {{end}}
{{else if .PageIsViewProjects}}
{{ctx.Locale.Tr "user.projects"}}
- {{svg "octicon-project-symlink" 16}}{{ctx.Locale.Tr "repo.issues.open_title"}}
- {{svg "octicon-check" 16}}{{ctx.Locale.Tr "repo.issues.closed_title"}}
+ {{/* The Open and Closed tabs live in the project list's header, as on GitHub. */}}
+ {{svg "octicon-table" 16}}{{ctx.Locale.Tr "user.projects"}}
{{else if .IsPackagesPage}}
{{ctx.Locale.Tr "packages.title"}}
{{svg "octicon-package" 16}}{{ctx.Locale.Tr "all"}}
diff --git a/templates/projects/card.tmpl b/templates/projects/card.tmpl
index 60ba0c2f..e0831598 100644
--- a/templates/projects/card.tmpl
+++ b/templates/projects/card.tmpl
@@ -1,6 +1,7 @@
-{{/* GitHub-style project card: the number above the title, with the repository in organization and user projects,
- assignees in the corner, then labels and compact details. Forgejo's repo/issue/card stays untouched for the pinned
- issues it also renders. */}}
+{{/* GitHub-style project card: the repository and number above the title with the card menu beside them, assignees in
+ the corner, then labels and compact details. Forgejo's repo/issue/card stays untouched for the pinned issues it also
+ renders. */}}
+{{$canWrite := and $.Page.CanWriteProjects (or (not $.Page.Repository) (not $.Page.Repository.IsArchived))}}
{{with .Issue}}
{{if eq $.Page.Project.CardType 1}}{{/* Images and Text*/}}
{{$attachments := index $.Page.issuesAttachmentMap .ID}}
@@ -15,7 +16,8 @@
{{template "shared/issueicon" .}}
- {{if not $.Page.Repository}}{{.Repo.Name}} {{end}}#{{.Index}}
+ {{.Repo.Name}} #{{.Index}}
+ {{template "components/common/dropdown/dropdown" dict "Kind" "actions" "Icon" "octicon-kebab-horizontal" "IconOnly" true "Small" true "Quiet" true "Label" (ctx.Locale.Tr "profile.actions.tooltip") "Items" "projects/card_menu" "Data" (dict "Context" $.Page.Context "Link" .Link "Columns" $.Page.Columns "CanWrite" $canWrite)}}
{{if .Assignees}}
{{range .Assignees}}
diff --git a/templates/projects/card_menu.tmpl b/templates/projects/card_menu.tmpl
new file mode 100644
index 00000000..48c06ccb
--- /dev/null
+++ b/templates/projects/card_menu.tmpl
@@ -0,0 +1,11 @@
+{{/* GitHub's card menu. Forgejo has no strings for the move actions, so they stay in English. The board script moves
+ the card and saves the new order through Forgejo's column move endpoint. */}}
+{{svg "octicon-copy" 16}}{{ctx.Locale.Tr "copy_url"}}
+{{if .CanWrite}}
+ {{svg "octicon-move-to-top" 16}}Move to top
+ {{svg "octicon-move-to-bottom" 16}}Move to bottom
+ Move to column
+ {{range .Columns}}
+ {{svg "octicon-arrow-right" 16}}{{.Title}}
+ {{end}}
+{{end}}
diff --git a/templates/repo/issue/sidebar.tmpl b/templates/repo/issue/sidebar.tmpl
index 5f412d42..c712497a 100644
--- a/templates/repo/issue/sidebar.tmpl
+++ b/templates/repo/issue/sidebar.tmpl
@@ -3,7 +3,7 @@