Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions components/common/dropdown/dropdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions components/project/list/actions.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<a class="theme-dropdown-action" href="{{.Link}}/edit">{{svg "octicon-pencil" 16}}{{ctx.Locale.Tr "repo.issues.label_edit"}}</a>
{{if .IsClosed}}
<a class="theme-dropdown-action link-action" href data-url="{{.Link}}/open">{{svg "octicon-issue-reopened" 16}}{{ctx.Locale.Tr "repo.projects.open"}}</a>
{{else}}
<a class="theme-dropdown-action link-action" href data-url="{{.Link}}/close">{{svg "octicon-issue-closed" 16}}{{ctx.Locale.Tr "repo.projects.close"}}</a>
{{end}}
<a class="theme-dropdown-action theme-dropdown-danger delete-button" href="#" data-url="{{.Link}}/delete" data-modal-id="delete-project">{{svg "octicon-trash" 16}}{{ctx.Locale.Tr "repo.issues.label_delete"}}</a>
168 changes: 168 additions & 0 deletions components/project/list/list.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
59 changes: 59 additions & 0 deletions components/project/list/list.tmpl
Original file line number Diff line number Diff line change
@@ -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}}
<section class="theme-project-list">
{{if .Heading}}
<div class="theme-list-heading">
<div class="theme-list-title"><button type="button" class="theme-icon-button" data-theme-sidebar-toggle data-theme-sidebar-expand aria-expanded="false" aria-label="Expand sidebar">{{svg "octicon-sidebar-expand" 16}}</button><h1>{{ctx.Locale.Tr "repo.projects"}}</h1></div>
{{if $newProject}}<a class="primary button theme-control" href="{{$newProject}}">{{ctx.Locale.Tr "repo.projects.new"}}</a>{{end}}
</div>
{{end}}
<div class="theme-project-list-toolbar">
<form class="ui form ignore-dirty theme-project-list-search" role="search">
<input type="hidden" name="state" value="{{$data.State}}">
<input type="hidden" name="sort" value="{{$data.SortType}}">
{{template "components/common/search/search" dict "Kind" "icon" "Value" $data.Keyword "Placeholder" (ctx.Locale.Tr "search.project_kind") "Label" (ctx.Locale.Tr "search.project_kind")}}
</form>
{{if and $newProject (not .Heading)}}<a class="primary button theme-control" href="{{$newProject}}">{{ctx.Locale.Tr "repo.projects.new"}}</a>{{end}}
</div>
<div class="theme-project-list-box">
<div class="theme-project-list-header">
<nav class="theme-project-list-states" aria-label="{{ctx.Locale.Tr "repo.projects"}}">
<a href="?state=open&q={{$data.Keyword}}&sort={{$data.SortType}}"{{if not $data.IsShowClosed}} aria-current="page"{{end}}>{{ctx.Locale.Tr "repo.issues.open_title"}} <span class="theme-counter">{{ctx.Locale.PrettyNumber $data.OpenCount}}</span></a>
<a href="?state=closed&q={{$data.Keyword}}&sort={{$data.SortType}}"{{if $data.IsShowClosed}} aria-current="page"{{end}}>{{ctx.Locale.Tr "repo.issues.closed_title"}} <span class="theme-counter">{{ctx.Locale.PrettyNumber $data.ClosedCount}}</span></a>
</nav>
{{template "components/common/dropdown/dropdown" dict "Kind" "actions" "Label" (ctx.Locale.Tr "repo.issues.filter_sort") "Quiet" true "Items" "components/project/list/sort" "Data" $data}}
</div>
<ul class="theme-project-list-rows">
{{range $data.Projects}}
<li class="theme-project-list-row">
<div class="theme-project-list-main">
<h3><a href="{{.Link ctx}}">{{.Title}}</a></h3>
<p class="theme-project-list-meta">#{{.ID}} {{ctx.Locale.Tr "issues.updated" (DateUtils.TimeSince .UpdatedUnix)}}</p>
{{if .Description}}<div class="theme-project-list-description markup">{{.RenderedContent}}</div>{{end}}
</div>
{{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}}
</li>
{{else}}
<li class="theme-empty theme-empty-compact">
{{svg "octicon-project" 24}}
<h3>{{ctx.Locale.Tr "search.no_results"}}</h3>
</li>
{{end}}
</ul>
</div>
{{template "base/paginate" $data}}
{{if $canWrite}}
<div class="ui g-modal-confirm delete modal" id="delete-project">
<div class="header">{{svg "octicon-trash"}}{{ctx.Locale.Tr "repo.projects.deletion"}}</div>
<div class="content"><p>{{ctx.Locale.Tr "repo.projects.deletion_desc"}}</p></div>
{{template "base/modal_actions_confirm" $data}}
</div>
{{end}}
</section>
5 changes: 5 additions & 0 deletions components/project/list/sort.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{$query := print "?state=" .State "&q=" .Keyword}}
<a class="theme-dropdown-action" href="{{$query}}&sort=newest"{{if or (eq .SortType "newest") (not .SortType)}} aria-current="true"{{end}}>{{svg "octicon-check" 16 "theme-project-list-check"}}{{ctx.Locale.Tr "issues.search.sort.latest"}}</a>
<a class="theme-dropdown-action" href="{{$query}}&sort=oldest"{{if eq .SortType "oldest"}} aria-current="true"{{end}}>{{svg "octicon-check" 16 "theme-project-list-check"}}{{ctx.Locale.Tr "issues.search.sort.oldest"}}</a>
<a class="theme-dropdown-action" href="{{$query}}&sort=recentupdate"{{if eq .SortType "recentupdate"}} aria-current="true"{{end}}>{{svg "octicon-check" 16 "theme-project-list-check"}}{{ctx.Locale.Tr "issues.search.sort.recentupdate"}}</a>
<a class="theme-dropdown-action" href="{{$query}}&sort=leastupdate"{{if eq .SortType "leastupdate"}} aria-current="true"{{end}}>{{svg "octicon-check" 16 "theme-project-list-check"}}{{ctx.Locale.Tr "issues.search.sort.leastupdate"}}</a>
66 changes: 66 additions & 0 deletions public/assets/js/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion scripts/assembleForgejo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading