+ } modules
+ * @property {string} repo
+ * @property {string} name
+ */
+
+ /**
+ * @type {ReadonlyArray}
+ */
+ packages = [];
+
+ /**
+ * @type {string}
+ */
+ filter = '';
+
+ /**
+ * @returns Promise<>
+ */
+ getPackages() {
+ return fetch(pkgURL, { headers: { 'X-Requested-With': 'XMLHttpRequest', Origin: 'https://caddyserver.com' } })
+ .then(res => res.json())
+ .then(({ result }) => this.packages = result.sort((a, b) => a.downloads - b.downloads).map(item => ({
+ ...item,
+ description: item.modules?.map(m => m.docs ?? m.name).join('\n') ?? '',
+ name: (item.repo || item.path).split('/').pop().toLowerCase(),
+ })));
+ }
+
+ setFilterValue(value) {
+ this.filter = value;
+ }
+
+ getSearchPackages(pkgs) {
+ if (!this.filter) {
+ return pkgs;
+ }
+
+ return pkgs.filter(pkg => pkg.name.includes(this.filter) || pkg.repo.includes(this.filter) || pkg.description.includes(this.filter));
+ }
+
+ /**
+ * @param {'alphabetically' | 'type' | 'download'} groupBy
+ * @return {
+ * Record | ReadonlyArray a.name.localeCompare(b.name));
+ case 'download':
+ return pkgs.sort((a, b) => b.downloads - a.downloads);
+ case 'type':
+ return pkgs.reduce((acc, current) => {
+ if (!current?.modules?.length) {
+
+ return acc;
+ }
+
+ current.modules.forEach(module => {
+ let moduleName = module.name
+ if (module.name.includes('.')) {
+ const splitted = module.name.split('.')
+ moduleName = `${splitted[0]}.${splitted[1]}`
+ }
+ if (acc[moduleName]) {
+ acc[moduleName] = [...acc[moduleName], current];
+ }
+ })
+
+ return acc;
+ }, {
+ "http.handlers": [],
+ "http.matchers": [],
+ "dns.providers": [],
+ "http.encoders": [],
+ "caddy.config_loaders": [],
+ "caddy.fs": [],
+ "caddy.listeners": [],
+ "caddy.logging.encoders": [],
+ "caddy.logging.encoders.filter": [],
+ "caddy.logging.writers": [],
+ "caddy.storage": [],
+ "events.handlers": [],
+ "http.authentication.hashes": [],
+ "http.authentication.providers": [],
+ "http.ip_sources": [],
+ "http.precompressed": [],
+ "http.reverse_proxy.circuit_breakers": [],
+ "http.reverse_proxy.selection_policies": [],
+ "http.reverse_proxy.transport": [],
+ "http.reverse_proxy.upstreams": [],
+ "tls.certificates": [],
+ "tls.client_auth": [],
+ "tls.handshake_match": [],
+ "tls.issuance": [],
+ "tls.get_certificate": [],
+ "tls.stek": [],
+ })
+ }
+ }
+}
+
+const packageManager = new Package();
+
+const params = new URLSearchParams(window.location.search?.slice(1));
+let versions = params.getAll('p').reduce((acc, current) => {
+ [p, v] = current.split('@');
+
+ acc[p] = v ?? '';
+
+ return acc;
+}, {});
+
+function togglePackage({ target: { dataset: { module } } }) {
+ const element = document.getElementById('packages').querySelector(`button[data-module="${module}"]`);
+ if (module in versions) {
+ delete versions[module];
+ const countVersions = Object.keys(versions).length;
+ if (!countVersions) {
+ modulesCount.innerHTML = '';
+ } else {
+ modulesCount.innerHTML = `with ${countVersions} extra module${countVersions > 1 ? 's' : ''}`;
+ }
+
+ element.innerHTML = "Add this module";
+ } else {
+ versions[module] = '';
+ const countVersions = Object.keys(versions).length;
+ element.innerHTML = "Remove this module";
+ modulesCount.innerHTML = `with ${countVersions} extra module${countVersions > 1 ? 's' : ''}`;
+ }
+
+ setDownloadLink();
+}
+
+function setDownloadLink() {
+ document.getElementById('command-builder').innerText = getCommand();
+ document
+ .getElementById('download-link')
+ .setAttribute('href', `${downloadURL}?${new URLSearchParams(Object.entries(versions).map(([p, v]) => ['p', `${p}${!!v ? `@${v}` : ''}`])).toString()}`);
+}
+
+function getCommand() {
+ return `xcaddy build${Object.entries(versions ?? {}).map(([p, v]) => ` --with ${p}${!!v ? `@${v}` : ''}`).join('')}`
+}
+
+function copyCommand() {
+ navigator.clipboard.writeText(getCommand())
+}
+
+function renderList(list) {
+ if (groupBy === 'type') {
+ const groupedData = Object.entries(packageManager.group(groupBy)).filter(([_, items]) => !!items.length)
+ document.getElementById('side-panel-packages').innerHTML = `
+
+ `).join('')
+ return;
+ }
+
+ document.getElementById('side-panel-packages').innerHTML = '';
+ document.getElementById('packages').innerHTML = `
+
+
-
+ Download the caddy binary for
+
+
+
+
+
+
+
+
+ xcaddy build
+
+
-
+
+
- Standard features: ☑️
-
+
+
+
+
+
+
+
-
-
- {{include "/old/includes/footer.html"}}
+
+
diff --git a/src/includes/card.html b/src/includes/card.html
new file mode 100644
index 0000000..e332d0a
--- /dev/null
+++ b/src/includes/card.html
@@ -0,0 +1,48 @@
+
-
-
- Extra features: 0
-
-
- Download
+
+
-
+ {{include "/includes/footer.html"}}
-
- ⚠️ Only choose plugins you need and trust
-
-
- ⚠️ Run the following against the downloaded binary:
-
-
- xattr -d com.apple.quarantine
-
-
-
-
-
+
\ No newline at end of file
diff --git a/src/resources/css/download.css b/src/resources/css/download.css
new file mode 100644
index 0000000..8c1c626
--- /dev/null
+++ b/src/resources/css/download.css
@@ -0,0 +1,298 @@
+* {
+ --border-download: 1px solid rgba(226, 232, 240, 0.8);
+ --radius: 0.5rem;
+}
+
+.card {
+ border: var(--border-download);
+ border-radius: 16px;
+ padding: 16px;
+ width: 100%;
+ height: 100%;
+ background-color: var(--body-bg);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+.shadow {
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .1), 0 1px 2px 0 rgba(0, 0, 0, .06);
+}
+
+.shadow-lg {
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+}
+
+.wrapper.list {
+ display: flex;
+}
+
+#side-panel-packages {
+ padding-bottom: 32px;
+}
+
+#side-panel-packages>div {
+ padding: 32px 1rem 1rem 0;
+ overflow-y: scroll;
+ width: 250px;
+ max-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ position: sticky;
+ top: 100px;
+}
+
+#download-link>button {
+ font-size: 1rem;
+}
+
+#platform {
+ width: unset;
+ min-height: unset;
+ height: unset;
+ line-height: unset;
+}
+
+#packages {
+ width: 100%;
+ padding-top: 32px;
+ padding-bottom: 32px;
+ display: grid;
+ gap: 48px;
+}
+
+#download {
+ position: sticky;
+ top: 0;
+ padding-bottom: 32px;
+ z-index: 10;
+}
+
+#download>div {
+ border: var(--border-download);
+ background-color: var(--body-bg);
+ border-radius: var(--radius);
+ padding: 1rem;
+}
+
+#download>div {
+ border: var(--border-download);
+ background-color: var(--body-bg);
+ border-radius: var(--radius);
+ padding: 1rem;
+}
+
+#downloader>button {
+ margin: 0 auto;
+ min-width: fit-content;
+ white-space: nowrap;
+ font-size: 80%;
+ font-weight: bold;
+}
+
+#downloader {
+ padding-bottom: 16px;
+}
+
+#command {
+ border-radius: var(--radius);
+ border: 1px solid var(--button-border-color);
+ padding: 0.5rem 0.75rem;
+ display: inline-flex;
+ width: 100%;
+}
+
+#command>pre {
+ width: 100%;
+ overflow-x: scroll;
+ display: inline-flex;
+ white-space: nowrap;
+ font-size: 1rem;
+}
+
+#command>svg {
+ height: 1em;
+ cursor: pointer;
+}
+
+#command-builder::before {
+ content: '$';
+ color: var(--text-color-muted);
+ margin-right: 0.25rem;
+}
+
+h2 {
+ padding-bottom: 16px;
+ color: rgb(14, 110, 189);
+ border-color: rgb(14, 110, 189);
+}
+
+.card-list {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 32px;
+}
+
+.card-header {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+}
+
+.card-title {
+ width: 100%;
+ display: flex;
+ padding-left: 16px;
+ justify-content: space-between;
+}
+
+.card-title-name {
+ display: flex;
+ flex-direction: column;
+ font-weight: lighter;
+ color: var(--text-color);
+}
+
+.card-title-name>a {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+ overflow-x: hidden;
+}
+
+.card-title-info {
+ display: inline-flex;
+ gap: 8px;
+ font-size: 90%;
+ color: var(--text-color-muted);
+}
+
+.card-title-info>span>svg {
+ margin-right: -4px;
+}
+
+.card-header>:first-child>span:first-child {
+ font-weight: bold;
+}
+
+.card-description {
+ display: flex;
+ color: var(--text-color-muted);
+ justify-content: space-between;
+ height: 100%;
+ gap: 8px;
+}
+
+.card-description>p {
+ font-weight: 400;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+ overflow: hidden;
+ word-break: break-word;
+}
+
+.card-actions {
+ margin-top: auto;
+}
+
+.card-button {
+ margin: 0 auto;
+ min-width: fit-content;
+ white-space: nowrap;
+ font-size: 80%;
+ font-weight: bold;
+}
+
+article {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.filters {
+ display: grid;
+ grid-template-columns: 2fr 1fr 2fr;
+ gap: 16px;
+}
+
+@media (max-width: 1400px) {
+ .card-description {
+ flex-direction: column;
+ }
+
+ .card-list {
+ grid-template-columns: 1fr;
+ }
+
+ .filters {
+ grid-template-columns: 1fr;
+ }
+
+ .wrapper.list {
+ flex-direction: column;
+ }
+
+ #side-panel-packages>div {
+ overflow: unset;
+ width: 100%;
+ }
+}
+
+input,
+select {
+ padding: 0.5rem;
+ height: 3rem;
+ font-size: 100%;
+ font-weight: 600;
+ border: 1px solid var(--button-border-color);
+ color: var(--text-color);
+ background-color: var(--body-bg);
+ width: 100%;
+ border-radius: var(--radius);
+}
+
+.filters>div {
+ flex-direction: column;
+}
+
+select {
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ padding-left: 1rem;
+ padding-right: 2.5rem;
+ font-size: .875rem;
+ line-height: 1.25rem;
+ line-height: 2;
+ min-height: 3rem;
+ border: 1px solid var(--button-border-color);
+ background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
+ background-position: calc(100% - 20px) calc(1px + 50%), calc(100% - 16.1px) calc(1px + 50%);
+ background-size: 4px 4px, 4px 4px;
+ background-repeat: no-repeat;
+}
+
+.package-version {
+ height: 50%;
+ font-size: 80%;
+ width: 4rem;
+ padding-top: unset;
+ padding-bottom: unset;
+}
+
+section {
+ padding: unset;
+ background-color: unset;
+}
+
+h3.blue {
+ padding-left: unset;
+ border: unset;
+}
\ No newline at end of file
diff --git a/src/resources/js/download.js b/src/resources/js/download.js
new file mode 100644
index 0000000..7e4d530
--- /dev/null
+++ b/src/resources/js/download.js
@@ -0,0 +1,206 @@
+const BASE_API_PATH = '/api';
+const pkgURL = `${BASE_API_PATH}/packages`;
+const downloadURL = `${BASE_API_PATH}/download`;
+
+class Package {
+ /**
+ * @typedef {Object} Module
+ * @property {string} docs
+ * @property {string} name
+ * @property {string} package
+ * @property {string} repo
+ */
+
+ /**
+ * @typedef {Object} Pkg
+ * @property {string} id
+ * @property {string} path
+ * @property {string} published
+ * @property {boolean} listed
+ * @property {boolean} available
+ * @property {number} downloads
+ * @property {ReadonlyArray
+
+
+
+ + ${item.name} +
+ + ${item.path} + +
+
+
+ ${item.downloads}
+
+
+
+
+
+
+
+
++ ${item.description} +
+
+
+
+
+
`;
+ document.getElementById('packages').innerHTML = groupedData.map(([category, items]) => `
+Namespaces
+${groupedData.map(([k]) => ` ${k}`).join('')} +${category}
+${items.map(item => getCardTemplate({ ...item, state: versions[item.path] })).join('')}
+
+${list.map(item => getCardTemplate({ ...item, state: versions[item.path] })).join('')}
+
`;
+};
+
+packageManager.getPackages().then(() => {
+ renderList(packageManager.group(groupBy));
+ const countVersions = Object.keys(versions).length;
+ modulesCount.innerHTML = countVersions ? `with ${countVersions} extra module${countVersions > 1 ? 's' : ''}` : '';
+ setDownloadLink();
+})
+
+function updateVersion({ target: { value } }, pkg) {
+ versions[pkg] = value;
+ setDownloadLink();
+}
\ No newline at end of file