diff --git a/com.woltlab.wcf/templates/shared_dateRangeFormField.tpl b/com.woltlab.wcf/templates/shared_dateRangeFormField.tpl
new file mode 100644
index 00000000000..b0507cd43c3
--- /dev/null
+++ b/com.woltlab.wcf/templates/shared_dateRangeFormField.tpl
@@ -0,0 +1,23 @@
+getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if}
+ {if $field->isAutofocused()} autofocus{/if}
+ {if $field->isRequired()} required{/if}
+ {if $field->isImmutable()} disabled{/if}
+ {foreach from=$field->getFieldAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}
+>
+getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if}
+ {if $field->isRequired()} required{/if}
+ {if $field->isImmutable()} disabled{/if}
+ {foreach from=$field->getFieldAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}
+>
diff --git a/com.woltlab.wcf/templates/shared_exceptionLogDetails.tpl b/com.woltlab.wcf/templates/shared_exceptionLogDetails.tpl
new file mode 100644
index 00000000000..2e87d96359d
--- /dev/null
+++ b/com.woltlab.wcf/templates/shared_exceptionLogDetails.tpl
@@ -0,0 +1,63 @@
+
+
+ -
+
+
+
+
+
+ - {lang}wcf.acp.exceptionLog.search.exceptionID{/lang}
+ - {$exceptionID}
+
+
+ - {lang}wcf.acp.exceptionLog.exception.date{/lang}
+ - {$exception[date]|plainTime}
+
+
+ - {lang}wcf.acp.exceptionLog.exception.requestURI{/lang}
+ - {$exception[requestURI]}
+
+
+ - {lang}wcf.acp.exceptionLog.exception.referrer{/lang}
+ - {$exception[referrer]}
+
+
+ - {lang}wcf.acp.exceptionLog.exception.userAgent{/lang}
+ - {$exception[userAgent]}
+
+
+ - {lang}wcf.acp.exceptionLog.exception.memory{/lang}
+ - {$exception[peakMemory]|filesizeBinary} / {if $exception[maxMemory] == -1}∞{else}{$exception[maxMemory]|filesizeBinary}{/if}
+
+{foreach from=$exception[chain] item=chain}
+
+ - {lang}wcf.acp.exceptionLog.exception.message{/lang}
+ - {$chain[message]}
+
+
+ - {lang}wcf.acp.exceptionLog.exception.class{/lang}
+ - {$chain[class]}
+
+
+ - {lang}wcf.acp.exceptionLog.exception.file{/lang}
+ - {$chain[file]} ({$chain[line]})
+
+ {if !$chain[information]|empty}
+ {foreach from=$chain[information] item=extraInformation}
+
+ - {$extraInformation[0]}
+ - {$extraInformation[1]}
+
+ {/foreach}
+ {/if}
+
+ - {lang}wcf.acp.exceptionLog.exception.stacktrace{/lang}
+ -
+
+ {foreach from=$chain[stack] item=stack}
+ - {$stack[file]} ({$stack[line]}): {$stack[class]}{$stack[type]}{$stack[function]}(…)
+ {/foreach}
+
+
+
+{/foreach}
diff --git a/com.woltlab.wcf/templates/shared_gridView.tpl b/com.woltlab.wcf/templates/shared_gridView.tpl
new file mode 100644
index 00000000000..370238ea28f
--- /dev/null
+++ b/com.woltlab.wcf/templates/shared_gridView.tpl
@@ -0,0 +1,73 @@
+
+ {if $view->isFilterable()}
+
+
+ {foreach from=$view->getActiveFilters() item='value' key='key'}
+
+ {/foreach}
+
+
+
+
+
+ {/if}
+
+
countRows()} hidden{/if}>
+
+
+
+ {unsafe:$view->renderRows()}
+
+
+
+
+
+
countRows()} hidden{/if}>{lang}wcf.global.noItems{/lang}
+
+
+
+{unsafe:$view->renderActionInitialization()}
diff --git a/com.woltlab.wcf/templates/shared_gridViewRows.tpl b/com.woltlab.wcf/templates/shared_gridViewRows.tpl
new file mode 100644
index 00000000000..e51236c0a54
--- /dev/null
+++ b/com.woltlab.wcf/templates/shared_gridViewRows.tpl
@@ -0,0 +1,25 @@
+
+{foreach from=$view->getRows() item='row'}
+
+ {foreach from=$view->getVisibleColumns() item='column'}
+
+ {unsafe:$view->renderColumn($column, $row)}
+ |
+ {/foreach}
+ {if $view->hasActions()}
+
+
+
+
+
+
+ |
+ {/if}
+
+{/foreach}
diff --git a/ts/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.ts b/ts/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.ts
new file mode 100644
index 00000000000..0edc37cf86a
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.ts
@@ -0,0 +1,38 @@
+/**
+ * Shows the dialog that shows exception details.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.1
+ */
+
+import { renderException } from "WoltLabSuite/Core/Api/Exceptions/RenderException";
+import { copyTextToClipboard } from "WoltLabSuite/Core/Clipboard";
+import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
+import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
+import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector";
+import { getPhrase } from "WoltLabSuite/Core/Language";
+
+async function showDialog(button: HTMLElement): Promise {
+ const response = await renderException(button.closest("tr")!.dataset.objectId!);
+ if (!response.ok) {
+ return;
+ }
+
+ const dialog = dialogFactory().fromHtml(response.value.template).withoutControls();
+ dialog.content.querySelector(".jsCopyButton")?.addEventListener("click", () => {
+ void copyTextToClipboard(dialog.content.querySelector(".jsCopyException")!.value);
+ });
+
+ dialog.show(getPhrase("wcf.acp.exceptionLog.exception.message"));
+}
+
+export function setup(): void {
+ wheneverFirstSeen(".jsExceptionLogEntry", (button) => {
+ button.addEventListener(
+ "click",
+ promiseMutex(() => showDialog(button)),
+ );
+ });
+}
diff --git a/ts/WoltLabSuite/Core/Api/DeleteObject.ts b/ts/WoltLabSuite/Core/Api/DeleteObject.ts
new file mode 100644
index 00000000000..1ae28ef6da6
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/DeleteObject.ts
@@ -0,0 +1,22 @@
+/**
+ * Deletes an object.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.1
+ * @woltlabExcludeBundle tiny
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "./Result";
+
+export async function deleteObject(endpoint: string): Promise> {
+ try {
+ await prepareRequest(endpoint).delete().fetchAsJson();
+ } catch (e) {
+ return apiResultFromError(e);
+ }
+
+ return apiResultFromValue([]);
+}
diff --git a/ts/WoltLabSuite/Core/Api/Exceptions/RenderException.ts b/ts/WoltLabSuite/Core/Api/Exceptions/RenderException.ts
new file mode 100644
index 00000000000..fc309d071ac
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/Exceptions/RenderException.ts
@@ -0,0 +1,28 @@
+/**
+ * Gets the html code for the rendering of a exception log entry.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.2
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";
+
+type Response = {
+ template: string;
+};
+
+export async function renderException(exceptionId: string): Promise> {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/exceptions/${exceptionId}/render`);
+
+ let response: Response;
+ try {
+ response = (await prepareRequest(url).get().fetchAsJson()) as Response;
+ } catch (e) {
+ return apiResultFromError(e);
+ }
+
+ return apiResultFromValue(response);
+}
diff --git a/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts b/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts
new file mode 100644
index 00000000000..a262a51b57c
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts
@@ -0,0 +1,43 @@
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";
+
+type Response = {
+ template: string;
+ pages: number;
+ totalRows: number;
+ filterLabels: ArrayLike;
+};
+
+export async function getRows(
+ gridViewClass: string,
+ pageNo: number,
+ sortField: string = "",
+ sortOrder: string = "ASC",
+ filters?: Map,
+ gridViewParameters?: Map,
+): Promise> {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/gridViews/rows`);
+ url.searchParams.set("gridView", gridViewClass);
+ url.searchParams.set("pageNo", pageNo.toString());
+ url.searchParams.set("sortField", sortField);
+ url.searchParams.set("sortOrder", sortOrder);
+ if (filters) {
+ filters.forEach((value, key) => {
+ url.searchParams.set(`filters[${key}]`, value);
+ });
+ }
+ if (gridViewParameters) {
+ gridViewParameters.forEach((value, key) => {
+ url.searchParams.set(`gridViewParameters[${key}]`, value);
+ });
+ }
+
+ let response: Response;
+ try {
+ response = (await prepareRequest(url).get().allowCaching().disableLoadingIndicator().fetchAsJson()) as Response;
+ } catch (e) {
+ return apiResultFromError(e);
+ }
+
+ return apiResultFromValue(response);
+}
diff --git a/ts/WoltLabSuite/Core/Component/GridView.ts b/ts/WoltLabSuite/Core/Component/GridView.ts
new file mode 100644
index 00000000000..d94577cbb0e
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Component/GridView.ts
@@ -0,0 +1,278 @@
+import { getRows } from "../Api/GridViews/GetRows";
+import DomUtil from "../Dom/Util";
+import { promiseMutex } from "../Helper/PromiseMutex";
+import UiDropdownSimple from "../Ui/Dropdown/Simple";
+import { dialogFactory } from "./Dialog";
+
+export class GridView {
+ readonly #gridClassName: string;
+ readonly #table: HTMLTableElement;
+ readonly #pagination: WoltlabCorePaginationElement;
+ readonly #baseUrl: string;
+ readonly #filterButton: HTMLButtonElement;
+ readonly #filterPills: HTMLElement;
+ readonly #noItemsNotice: HTMLElement;
+ #pageNo: number;
+ #sortField: string;
+ #sortOrder: string;
+ #defaultSortField: string;
+ #defaultSortOrder: string;
+ #filters: Map;
+ #gridViewParameters?: Map;
+
+ constructor(
+ gridId: string,
+ gridClassName: string,
+ pageNo: number,
+ baseUrl: string = "",
+ sortField = "",
+ sortOrder = "ASC",
+ gridViewParameters?: Map,
+ ) {
+ this.#gridClassName = gridClassName;
+ this.#table = document.getElementById(`${gridId}_table`) as HTMLTableElement;
+ this.#pagination = document.getElementById(`${gridId}_pagination`) as WoltlabCorePaginationElement;
+ this.#filterButton = document.getElementById(`${gridId}_filterButton`) as HTMLButtonElement;
+ this.#filterPills = document.getElementById(`${gridId}_filters`) as HTMLElement;
+ this.#noItemsNotice = document.getElementById(`${gridId}_noItemsNotice`) as HTMLElement;
+ this.#pageNo = pageNo;
+ this.#baseUrl = baseUrl;
+ this.#sortField = sortField;
+ this.#defaultSortField = sortField;
+ this.#sortOrder = sortOrder;
+ this.#defaultSortOrder = sortOrder;
+ this.#gridViewParameters = gridViewParameters;
+
+ this.#initPagination();
+ this.#initSorting();
+ this.#initActions();
+ this.#initFilters();
+
+ window.addEventListener("popstate", () => {
+ this.#handlePopState();
+ });
+ }
+
+ #initPagination(): void {
+ this.#pagination.addEventListener("switchPage", (event: CustomEvent) => {
+ void this.#switchPage(event.detail);
+ });
+ }
+
+ #initSorting(): void {
+ this.#table
+ .querySelectorAll('.gridView__headerColumn[data-sortable="1"]')
+ .forEach((element) => {
+ const button = element.querySelector(".gridView__headerColumn__button");
+ button?.addEventListener("click", () => {
+ this.#sort(element.dataset.id!);
+ });
+ });
+
+ this.#renderActiveSorting();
+ }
+
+ #sort(sortField: string): void {
+ if (this.#sortField == sortField && this.#sortOrder == "ASC") {
+ this.#sortOrder = "DESC";
+ } else {
+ this.#sortField = sortField;
+ this.#sortOrder = "ASC";
+ }
+
+ this.#switchPage(1);
+ this.#renderActiveSorting();
+ }
+
+ #renderActiveSorting(): void {
+ this.#table.querySelectorAll('th[data-sortable="1"]').forEach((element) => {
+ element.classList.remove("active", "ASC", "DESC");
+
+ if (element.dataset.id == this.#sortField) {
+ element.classList.add("active", this.#sortOrder);
+ }
+ });
+ }
+
+ #switchPage(pageNo: number, updateQueryString: boolean = true): void {
+ this.#pagination.page = pageNo;
+ this.#pageNo = pageNo;
+
+ void this.#loadRows(updateQueryString);
+ }
+
+ async #loadRows(updateQueryString: boolean = true): Promise {
+ const response = (
+ await getRows(
+ this.#gridClassName,
+ this.#pageNo,
+ this.#sortField,
+ this.#sortOrder,
+ this.#filters,
+ this.#gridViewParameters,
+ )
+ ).unwrap();
+ DomUtil.setInnerHtml(this.#table.querySelector("tbody")!, response.template);
+
+ this.#table.hidden = response.totalRows == 0;
+ this.#noItemsNotice.hidden = response.totalRows != 0;
+ this.#pagination.count = response.pages;
+
+ if (updateQueryString) {
+ this.#updateQueryString();
+ }
+
+ this.#renderFilters(response.filterLabels);
+ this.#initActions();
+ }
+
+ #updateQueryString(): void {
+ if (!this.#baseUrl) {
+ return;
+ }
+
+ const url = new URL(this.#baseUrl);
+
+ const parameters: [string, string][] = [];
+ if (this.#pageNo > 1) {
+ parameters.push(["pageNo", this.#pageNo.toString()]);
+ }
+ if (this.#sortField) {
+ parameters.push(["sortField", this.#sortField]);
+ parameters.push(["sortOrder", this.#sortOrder]);
+ }
+ if (this.#filters) {
+ this.#filters.forEach((value, key) => {
+ parameters.push([`filters[${key}]`, value]);
+ });
+ }
+
+ if (parameters.length > 0) {
+ url.search += url.search !== "" ? "&" : "?";
+ url.search += new URLSearchParams(parameters).toString();
+ }
+
+ window.history.pushState({}, document.title, url.toString());
+ }
+
+ #initActions(): void {
+ this.#table.querySelectorAll("tbody tr").forEach((row) => {
+ row.querySelectorAll(".gridViewActions").forEach((element) => {
+ const dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!)!;
+ dropdown?.querySelectorAll("[data-action]").forEach((element) => {
+ element.addEventListener("click", () => {
+ row.dispatchEvent(
+ new CustomEvent("action", {
+ detail: element.dataset,
+ bubbles: true,
+ }),
+ );
+ });
+ });
+ });
+ });
+ }
+
+ #initFilters(): void {
+ if (!this.#filterButton) {
+ return;
+ }
+
+ this.#filterButton.addEventListener(
+ "click",
+ promiseMutex(() => this.#showFilterDialog()),
+ );
+
+ if (!this.#filterPills) {
+ return;
+ }
+
+ const filterButtons = this.#filterPills.querySelectorAll("[data-filter]");
+ if (!filterButtons.length) {
+ return;
+ }
+
+ this.#filters = new Map();
+ filterButtons.forEach((button) => {
+ this.#filters.set(button.dataset.filter!, button.dataset.filterValue!);
+ button.addEventListener("click", () => {
+ this.#removeFilter(button.dataset.filter!);
+ });
+ });
+ }
+
+ async #showFilterDialog(): Promise {
+ const url = new URL(this.#filterButton.dataset.endpoint!);
+ if (this.#filters) {
+ this.#filters.forEach((value, key) => {
+ url.searchParams.set(`filters[${key}]`, value);
+ });
+ }
+
+ const { ok, result } = await dialogFactory().usingFormBuilder().fromEndpoint(url.toString());
+
+ if (ok) {
+ this.#filters = new Map(Object.entries(result as ArrayLike));
+ this.#switchPage(1);
+ }
+ }
+
+ #renderFilters(labels: ArrayLike): void {
+ if (!this.#filterPills) {
+ return;
+ }
+ this.#filterPills.innerHTML = "";
+ if (!this.#filters) {
+ return;
+ }
+
+ this.#filters.forEach((value, key) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.classList.add("button", "small");
+ const icon = document.createElement("fa-icon");
+ icon.setIcon("circle-xmark");
+ button.append(icon, labels[key]);
+ button.addEventListener("click", () => {
+ this.#removeFilter(key);
+ });
+
+ this.#filterPills.append(button);
+ });
+ }
+
+ #removeFilter(filter: string): void {
+ this.#filters.delete(filter);
+ this.#switchPage(1);
+ }
+
+ #handlePopState(): void {
+ let pageNo = 1;
+ this.#sortField = this.#defaultSortField;
+ this.#sortOrder = this.#defaultSortOrder;
+ this.#filters = new Map();
+
+ const url = new URL(window.location.href);
+ url.searchParams.forEach((value, key) => {
+ if (key === "pageNo") {
+ pageNo = parseInt(value, 10);
+ return;
+ }
+
+ if (key === "sortField") {
+ this.#sortField = value;
+ }
+
+ if (key === "sortOrder") {
+ this.#sortOrder = value;
+ }
+
+ const matches = key.match(/^filters\[([a-z0-9_]+)\]$/i);
+ if (matches) {
+ this.#filters.set(matches[1], value);
+ }
+ });
+
+ this.#switchPage(pageNo, false);
+ }
+}
diff --git a/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts b/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts
new file mode 100644
index 00000000000..e3a81d1ace0
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts
@@ -0,0 +1,28 @@
+import { deleteObject } from "WoltLabSuite/Core/Api/DeleteObject";
+import { confirmationFactory } from "../../Confirmation";
+import * as UiNotification from "WoltLabSuite/Core/Ui/Notification";
+
+async function handleDelete(row: HTMLTableRowElement, objectName: string, endpoint: string): Promise {
+ const confirmationResult = await confirmationFactory().delete(objectName);
+ if (!confirmationResult) {
+ return;
+ }
+
+ const result = await deleteObject(endpoint);
+ if (!result.ok) {
+ return;
+ }
+
+ row.remove();
+
+ // TODO: This shows a generic success message and should be replaced with a more specific message.
+ UiNotification.show();
+}
+
+export function setup(table: HTMLTableElement): void {
+ table.addEventListener("action", (event: CustomEvent) => {
+ if (event.detail.action === "delete") {
+ void handleDelete(event.target as HTMLTableRowElement, event.detail.objectName, event.detail.endpoint);
+ }
+ });
+}
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/DateRange.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/DateRange.ts
new file mode 100644
index 00000000000..3da01732f87
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Form/Builder/Field/DateRange.ts
@@ -0,0 +1,43 @@
+/**
+ * Data handler for a date range form builder field in an Ajax form.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.2
+ */
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import DatePicker from "../../../Date/Picker";
+
+class DateRange extends Field {
+ #fromField: HTMLElement | null;
+ #toField: HTMLElement | null;
+
+ constructor(fieldId: string) {
+ super(fieldId);
+
+ this.#fromField = document.getElementById(this._fieldId + "_from");
+ if (this.#fromField === null) {
+ throw new Error("Unknown field with id '" + this._fieldId + "'.");
+ }
+
+ this.#toField = document.getElementById(this._fieldId + "_to");
+ if (this.#toField === null) {
+ throw new Error("Unknown field with id '" + this._fieldId + "'.");
+ }
+ }
+
+ protected _getData(): FormBuilderData {
+ return {
+ [this._fieldId]: {
+ from: DatePicker.getValue(this.#fromField as HTMLInputElement),
+ to: DatePicker.getValue(this.#toField as HTMLInputElement),
+ },
+ };
+ }
+
+ protected _readField(): void {}
+}
+
+export = DateRange;
diff --git a/wcfsetup/install/files/acp/js/WCF.ACP.js b/wcfsetup/install/files/acp/js/WCF.ACP.js
index d559e5869da..b2d23e5a848 100644
--- a/wcfsetup/install/files/acp/js/WCF.ACP.js
+++ b/wcfsetup/install/files/acp/js/WCF.ACP.js
@@ -96,12 +96,6 @@ WCF.ACP.Cronjob.ExecutionHandler = Class.extend({
* Handles the cronjob log list.
*/
WCF.ACP.Cronjob.LogList = Class.extend({
- /**
- * error message dialog
- * @var jQuery
- */
- _dialog: null,
-
/**
* Initializes WCF.ACP.Cronjob.LogList object.
*/
@@ -123,29 +117,6 @@ WCF.ACP.Cronjob.LogList = Class.extend({
}
});
});
-
- // bind event listeners to error badges
- $('.jsCronjobError').click($.proxy(this._showError, this));
- },
-
- /**
- * Shows certain error message
- *
- * @param object event
- */
- _showError: function(event) {
- var $errorBadge = $(event.currentTarget);
-
- if (this._dialog === null) {
- this._dialog = $('' + $errorBadge.next().html() + '
').hide().appendTo(document.body);
- this._dialog.wcfDialog({
- title: WCF.Language.get('wcf.acp.cronjob.log.error.details')
- });
- }
- else {
- this._dialog.html('' + $errorBadge.next().html() + '
');
- this._dialog.wcfDialog('open');
- }
}
});
diff --git a/wcfsetup/install/files/acp/templates/cronjobLogList.tpl b/wcfsetup/install/files/acp/templates/cronjobLogList.tpl
index af482fc14c2..980e7e5bf88 100644
--- a/wcfsetup/install/files/acp/templates/cronjobLogList.tpl
+++ b/wcfsetup/install/files/acp/templates/cronjobLogList.tpl
@@ -13,14 +13,14 @@
-
-
-{assign var='linkParameters' value=''}
-{if $cronjobID}{capture append=linkParameters}&cronjobID={@$cronjobID}{/capture}{/if}
-{if $success != -1}{capture append=linkParameters}&success={@$success}{/capture}{/if}
-
-{hascontent}
-
-{/hascontent}
-
-{if $objects|count}
-
-
-
-{else}
- {lang}wcf.global.noItems{/lang}
-{/if}
+{unsafe:$gridView->render()}
{include file='footer'}
diff --git a/wcfsetup/install/files/acp/templates/exceptionLogView.tpl b/wcfsetup/install/files/acp/templates/exceptionLogView.tpl
index f33ad72f1a8..b8776950077 100644
--- a/wcfsetup/install/files/acp/templates/exceptionLogView.tpl
+++ b/wcfsetup/install/files/acp/templates/exceptionLogView.tpl
@@ -1,22 +1,4 @@
{include file='header' pageTitle='wcf.acp.exceptionLog'}
-