Compare commits

...
Sign in to create a new pull request.

46 commits

Author SHA1 Message Date
Alicia Sykes
98944c5549 Adds error monitoring 2023-09-03 21:04:34 +01:00
Alicia Sykes
72574845ad Adds dependency for GlitchTip Svelte integration 2023-09-03 21:04:06 +01:00
Alicia Sykes
ad9190ba5a
Create README.txt 2023-04-23 16:47:18 +01:00
Alicia Sykes
8688e0a3e0 Adds sitemap 2023-04-23 16:06:03 +01:00
Alicia Sykes
464812562e Updates tags 2023-04-23 15:41:53 +01:00
Alicia Sykes
7103b0d404 Updates meta tags 2023-04-23 15:37:30 +01:00
Alicia Sykes
36246d81b1 Does some stuff 2023-04-23 15:31:12 +01:00
Alicia Sykes
714a528bce Adds meta tags 2023-04-23 15:30:58 +01:00
Alicia Sykes
e90e984b72 Adds banner image for social previews 2023-04-23 15:30:45 +01:00
Alicia Sykes
ad09771f54 Extracts docker conversion logic to own file, plus some other stuff 2023-04-23 13:32:01 +01:00
Alicia Sykes
7d039bbff8 Adds icons to docker stats 2023-04-23 13:31:39 +01:00
Alicia Sykes
c3d7694419 Adds syntax highlighting to docker-compose files 2023-04-23 13:31:07 +01:00
Alicia Sykes
9c95bc5e82 Improves reusability of stylings and components 2023-04-22 22:23:45 +01:00
Alicia Sykes
f39c382a91 Reusable button component 2023-04-22 22:23:29 +01:00
Alicia Sykes
391f483a05 Display dynamically fetched stats 2023-04-22 21:08:10 +01:00
Alicia Sykes
774de9c902 Improved filter displays 2023-04-22 21:07:53 +01:00
Alicia Sykes
025c8f4642 Lazy load container logos, for better performance 2023-04-22 21:07:02 +01:00
Alicia Sykes
eb3e5e5ade Adds components for showing Docker stats and markdown docs 2023-04-22 21:06:32 +01:00
Alicia Sykes
112a8b62b3 Adds dynamically generated installation instructions 2023-04-22 21:04:41 +01:00
Alicia Sykes
2ae6293544 Nav bar component 2023-04-22 21:04:19 +01:00
Alicia Sykes
0f4d9e5c20 Updated URLs to constants in Hero 2023-04-22 21:03:29 +01:00
Alicia Sykes
3ed36f61a6 Option for sticky footer 2023-04-22 21:02:59 +01:00
Alicia Sykes
281021fd12 Adds transitions to category show/hide 2023-04-22 21:02:39 +01:00
Alicia Sykes
e93451121d Adds repo link to constants 2023-04-22 21:02:04 +01:00
Alicia Sykes
da37cae7b0 Adds typings for DockerHub API response 2023-04-21 21:01:41 +01:00
Alicia Sykes
ba61f69f1a Adds markdown parsing dependency 2023-04-21 21:01:13 +01:00
Alicia Sykes
ab2b73cdd4 Adds js-yaml 2023-04-21 11:10:12 +01:00
Alicia Sykes
56b52ade1e Services 2023-04-21 11:10:00 +01:00
Alicia Sykes
11e43843a8 Updates how data fetching and caching is done 2023-04-19 21:15:01 +01:00
Alicia Sykes
30418d0ffe Makes templates into clickable links 2023-04-19 21:14:45 +01:00
Alicia Sykes
cd6e3342cf todo, build notfound template compo 2023-04-19 21:14:25 +01:00
Alicia Sykes
93b9dcb68a Starts working on template page 2023-04-19 21:14:07 +01:00
Alicia Sykes
983ac541ad Configures a file import alias for ./src 2023-04-19 21:13:37 +01:00
Alicia Sykes
12171606e9 Adds type definitions for Templates 2023-04-19 21:13:11 +01:00
Alicia Sykes
fef32824de Move globals to layout 2023-04-19 21:12:55 +01:00
Alicia Sykes
ce669212f0 Cache fectech data in store 2023-04-19 21:12:38 +01:00
Alicia Sykes
7280e4b0ab File to store constants 2023-04-19 21:12:26 +01:00
Alicia Sykes
75266a1915 Adds SEO/social tags 2023-04-17 23:25:57 +01:00
Alicia Sykes
9f21da5ad0 Wrap on small screens 2023-04-17 23:25:47 +01:00
Alicia Sykes
bf9a4585fe Change the content to be server-side rendered 2023-04-17 22:52:47 +01:00
Alicia Sykes
7bb4421d0d Import manifest 2023-04-17 22:52:23 +01:00
Alicia Sykes
5c0c537aaa Adds a manifest, and some app icons 2023-04-17 22:52:04 +01:00
Alicia Sykes
688ad6420e Writes quick Dockerfile, cos - why not? 2023-04-17 22:44:38 +01:00
Alicia Sykes
cb8fcf5df2 Adds traffic counter 2023-04-17 22:44:02 +01:00
Alicia Sykes
2fb9a402eb Builds a website 2023-04-17 22:07:40 +01:00
Alicia Sykes
4d5dbf26c4 deletes everything 2023-04-16 21:31:44 +01:00
61 changed files with 6403 additions and 15807 deletions

122
.github/README.md vendored
View file

@ -1,122 +0,0 @@
<h1 align="center">Portainer Templates</h1>
<p align="center"><i>A compiled list of 400+ ready to go Portainer App templates</i></p>
<p align="center">
<img width="200" src="https://i.ibb.co/hMymwH0/portainer-templates-small.png" />
</p>
> **TL;DR** Under Settings → App Templates in your Portainer GUI, paste this URL:<br>
> `https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json`<br>
## Intro
In Portainer, [App Templates](https://docs.portainer.io/user/docker/templates) enable you to easily deploy a container with a predetermined configuration, while allowing you to customize options through the web UI. Both single containers, and stacks are supported. While Portainer ships with some default templates (see [portainer/templates](https://github.com/portainer/templates)), it's often helpful to have 1-click access to many more apps, without having to constantly switch template sources.
This repo combines app templates from several [sources](#sources), to create a ready-to-go template file containing all the apps you'll ever need.
---
## Usage
1. Log into your Portainer web UI
2. Under Settings --> App Templates, update the URL to
- `https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json`
3. Now under Home --> App Templates, you should see all apps. Click one to deploy.
Alternatively, when you start Portainer, you can append the `--templates` flag pointing to the templates URL.
---
## Self-Hosting
```
git clone https://github.com/lissy93/portainer-templates.git portainer-templates
cd portainer-templates
docker build -t portainer-templates .
docker run -d -p "8080:80" portainer-templates
```
Your templates file will then be served up, at: `http://docker-host:8080/templates.json`
Or, to mount the `templates.json` file to your container, so that you can make changes to it, and have them show up within Portainer
```
docker run -d -p "8080:80" -v "${PWD}/templates.json:/usr/share/nginx/html/templates.json" portainer-templates
```
---
## Editing
The `template.json` file is generated using the scripts in [`lib`](https://github.com/Lissy93/portainer-templates/tree/main/lib), using GitHub Actions.
Running the `make` command will download all listed sources, parse them, and combine them outputting the `templates.json` file.
### Adding a new Source
Just place a link to the source, along with your chosen name in the [`sources.csv`](https://github.com/Lissy93/portainer-templates/blob/main/sources.csv) file.
When the action runs, it will download the content, parse it and add it to the final template.
### Adding a Template / Template list
Alternatively, place your template file within the [`sources`](https://github.com/Lissy93/portainer-templates/tree/main/sources) directory, and it will be automatically combined into the main `template.json`.
Be sure that your template corresponds to [Portainer's App Template JSON Format](https://docs.portainer.io/advanced/app-templates/format).
### Validating Templates
There is a schema defined in [`Schema.json`](https://github.com/Lissy93/portainer-templates/blob/main/Schema.json), which can be used to validate any Portainer template.
Run `make validate` to ensure your template conforms to Portainer's App Template [specification](https://docs.portainer.io/advanced/app-templates/format).
### Maintaining your own Templates
If you'd prefer to maintain your own templates, while using the templates included here as a base, then fork the repository, and update `lissy93` with your GitHub username
---
## Sources
The templates here are composed from the following sources. Full credit to the authors of each
- [dnburgess](https://github.com/dnburgess/self-hosted-template) <sup>[`template.json`](https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json)</sup>
- [qballjos](https://github.com/Qballjos/portainer_templates) <sup>[`template.json`](https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json)</sup>
- [SelfhostedPro](https://github.com/SelfhostedPro/selfhosted_templates) <sup>[`template.json`](https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json)</sup>
- [technorabilia](https://github.com/technorabilia/portainer-templates) <sup>[`template.json`](https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates-2.0.json)</sup>
- [mikestraney](https://github.com/mikestraney/portainer-templates) <sup>[`template.json`](https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json)</sup>
- [xneo1](https://github.com/xneo1/portainer_templates) <sup>[`template.json`](https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json)</sup>
---
## Supported Apps and Stacks
---
## Contributing
---
## Disclaimer
---
## License
<!-- License + Copyright -->
<p align="center">
<i>© <a href="https://aliciasykes.com">Alicia Sykes</a> 2023</i><br>
<i>Licensed under <a href="https://gist.github.com/Lissy93/143d2ee01ccc5c052a17">MIT</a></i><br>
<a href="https://github.com/lissy93"><img src="https://i.ibb.co/4KtpYxb/octocat-clean-mini.png" /></a><br>
<sup>Thanks for visiting :)</sup>
</p>
<!-- Dinosaur -->
<!--
. - ~ ~ ~ - .
.. _ .-~ ~-.
//| \ `..~ `.
|| | } } / \ \
(\ \\ \~^..' | } \
\`.-~ o / } | / \
(__ | / | / `.
`- - ~ ~ -._| /_ - ~ ~ ^| /- _ `.
| / | / ~-. ~- _
|_____| |_____| ~ - . _ _~_-_
-->

View file

@ -1,47 +0,0 @@
name: 🏗️ Build + Publish templates.json file
on:
workflow_dispatch:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
# Checkout repo
- name: Checkout repository 🛎️
uses: actions/checkout@v2
# Get current date-time (used for commit message)
- name: Get Date 📅
id: date
run: echo "::set-output name=date::$(date +'%d-%b-%Y')"
# Downloads + installs Python (used for running gen scripts)
- name: Set up Python 🐍
uses: actions/setup-python@v2
with:
python-version: '3.x'
# Install contents of requirements.txt
- name: Install dependencies 📥
run: |
python -m pip install --upgrade pip
cd lib && pip install -r requirements.txt
# The make command triggers all the Python scripts, generates output
- name: Run make command 🔨
run: make
# Commit and push the outputed JSON files
- name: Commit and push generated files ⤴️
run: |
git config --global user.name "Liss-Bot"
git config --global user.email "alicia-gh-bot@mail.as93.net"
git add templates.json
if git diff --staged --quiet; then
echo "Nothin new added, so nothing to commit, exiting..."
exit 0
else
git commit -m "Updates templates (auto-generated, on ${{ steps.date.outputs.date }})"
git push
fi

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

View file

@ -1,5 +1,55 @@
FROM nginx:stable-alpine
FROM node:18-alpine AS BUILD_IMAGE
COPY templates.json /usr/share/nginx/html/templates.json
# Set the platform to build image for
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
EXPOSE 80
# Get environment variables
ARG NODE_ENV
# Install additional tools needed if on arm64 / armv7
RUN \
case "${TARGETPLATFORM}" in \
'linux/arm64') apk add --no-cache python3 make g++ ;; \
'linux/arm/v7') apk add --no-cache python3 make g++ ;; \
'linux/arm64/v8') apk add --no-cache python3 make g++ ;; \
esac
# Create and set the working directory
WORKDIR /app
# Install app dependencies
COPY package.json package-lock.json ./
RUN npm install
# Copy over all project files and folders to the working directory
COPY . ./
# Build initial app for production
RUN npm run build
# Production stage
FROM node:18-alpine
# Define some ENV Vars
ENV PORT=80 \
DIRECTORY=/app \
IS_DOCKER=true
# Create and set the working directory
WORKDIR ${DIRECTORY}
# Update tzdata for setting timezone
RUN apk add --no-cache tzdata
# Copy built application from build phase
COPY --from=BUILD_IMAGE /app ./
# Finally, run start command to serve up the built application
CMD [ "npm", "start" ]
# Expose the port
EXPOSE ${PORT}
# Run simple healthchecks every 5 mins, to check that everythings still great
HEALTHCHECK --interval=5m --timeout=5s --start-period=30s CMD yarn health-check

View file

@ -1,15 +0,0 @@
.PHONY: all install_requirements download combine
all: install_requirements download combine
install_requirements:
pip install -r lib/requirements.txt
download:
python lib/download.py
combine:
python lib/combine.py
validate:
python lib/validate.py

25
README.txt Normal file
View file

@ -0,0 +1,25 @@
____ _ _
| _ \ ___ _ __| |_ __ _(_)_ __ ___ _ __
| |_) / _ \| '__| __/ _` | | '_ \ / _ \ '__|
| __/ (_) | | | || (_| | | | | | __/ |
|_|___\___/|_| \__\__,_|_|_| |_|\___|_|
|_ _|__ _ __ ___ _ __ | | __ _| |_ ___ ___
| |/ _ \ '_ ` _ \| '_ \| |/ _` | __/ _ \/ __|
| | __/ | | | | | |_) | | (_| | || __/\__ \
|_|\___|_| |_| |_| .__/|_|\__,_|\__\___||___/
|_|
This branch contains only the source for the website which is published at: https://portainer-templates.as93.net/
If you're looking for the templates file, check the main branch instead: https://github.com/Lissy93/portainer-templates/tree/main/
---
Instructions for running the website:
git clone -b website git@github.com:Lissy93/portainer-templates.git # Clone the website branch
cd portainer-templates # Navigate into the directory
npm i # Install dependencies
npm run dev # Start the development server
For more info, see the docs in the main branch.

View file

@ -1,112 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "PortainerAppTemplate",
"properties": {
"version": {
"type": "string",
"minLength": 1,
"description": "The version of the Portainer App Template."
},
"templates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "integer",
"minimum": 1,
"maximum": 2,
"description": "The type of the application (1 for container, 2 for swarm stack)."
},
"title": {
"type": "string",
"minLength": 1,
"description": "The title of the application."
},
"description": {
"type": "string",
"minLength": 1,
"description": "A brief description of the application."
},
"categories": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "An array of categories the application belongs to."
},
"platform": {
"type": "string",
"minLength": 1,
"description": "The target platform of the application (e.g., 'linux', 'windows')."
},
"logo": {
"type": "string",
"format": "uri",
"description": "A URI to the logo of the application."
},
"image": {
"type": "string",
"minLength": 1,
"description": "The name of the Docker image used for the application."
},
"restart_policy": {
"type": "string",
"enum": ["always", "unless-stopped", "on-failure", "no"],
"description": "The restart policy for the application."
},
"ports": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[0-9]+:[0-9]+(/tcp|/udp)?$",
"description": "A port mapping in the format 'hostPort:containerPort/protocol'."
},
"description": "An array of port mappings for the application."
},
"volumes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"bind": {
"type": "string",
"minLength": 1,
"description": "The host path for the volume binding."
},
"container": {
"type": "string",
"minLength": 1,
"description": "The container path for the volume binding."
}
},
"required": ["bind", "container"]
},
"description": "An array of volume mappings for the application."
},
"environment": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "The name of the environment variable."
},
"label": {
"type": "string"
}
}
}
}
},
"required": ["type", "title", "description", "categories", "platform", "logo", "image"]
}
}
},
"required": ["version", "templates"]
}

View file

@ -1,35 +0,0 @@
import os
import json
# Get list of files in sources
dir = os.path.dirname(os.path.abspath(__file__))
templates_src_dir = os.path.join(dir, '../sources/')
template_dest_file = os.path.join(dir, '../templates.json')
files = os.listdir(templates_src_dir)
# Initialize empty list to store template objects
templates = []
# For each file in sources
for file in files:
# Open the file
with open(templates_src_dir + file) as f:
if file.endswith('.json'):
# Load the JSON into a variable
data = json.load(f)['templates']
# Append the template object to the templates list
templates = templates + data
# Remove duplicates
seen_titles = set()
filtered_data = [x for x in templates if x['title'] not in seen_titles and not seen_titles.add(x['title'])]
fileData = {
'version': '2',
'templates': filtered_data
}
# Open the templates.json file, and write results to it
with open(template_dest_file, 'w') as f:
json.dump(fileData, f, indent=2, sort_keys=False)

View file

@ -1,40 +0,0 @@
import os
import csv
import requests
dir = os.path.dirname(os.path.abspath(__file__))
destination_dir = os.path.join(dir, '../sources')
sources_list = os.path.join(dir, '../sources.csv')
# Downloads the file from a given URL, to the local destination
def download(url: str, filename: str):
file_path = os.path.join(destination_dir, filename)
r = requests.get(url, stream=True)
if r.ok:
print('saving to', os.path.abspath(file_path))
with open(file_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=1024 * 8):
if chunk:
f.write(chunk)
f.flush()
os.fsync(f.fileno())
else: # HTTP status code 4XX/5XX
print('Download failed: status code {}\n{}'.format(r.status_code, r.text))
# Gets list of URLs to download from CSV file
def get_source_list():
sources=[]
with open(sources_list, mode='r') as file:
csvFile = csv.reader(file)
for lines in csvFile:#
sources.append(lines)
return sources
# Create destination folder if not yet present
if not os.path.exists(destination_dir):
os.makedirs(destination_dir)
# For each source, download the templates JSON file
for sourceUrl in get_source_list():
download(sourceUrl[1], sourceUrl[0] + '.json')

View file

@ -1,2 +0,0 @@
requests
jsonschema

View file

@ -1,35 +0,0 @@
import json
import os
import sys
from jsonschema import validate, ValidationError
def load_json_file(file_path):
with open(file_path, 'r') as file:
return json.load(file)
def main():
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
schema_file = os.path.join(script_dir, '..', 'Schema.json')
templates_file = os.path.join(script_dir, '..', 'templates.json')
schema = load_json_file(schema_file)
templates = load_json_file(templates_file)
validate(instance=templates, schema=schema)
print('✅ templates.json is valid against the schema')
except ValidationError as ve:
print('Validation error:', ve.message)
sys.exit(1)
except FileNotFoundError as fnfe:
print(f'File not found error: {fnfe}')
sys.exit(1)
except json.JSONDecodeError as jde:
print(f'JSON decoding error: {jde}')
sys.exit(1)
if __name__ == '__main__':
main()

4124
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "portainer-templates",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.5.0",
"js-yaml": "^4.1.0",
"sass": "^1.62.0",
"snarkdown": "^2.0.0",
"svelte": "^3.54.0",
"svelte-check": "^3.0.1",
"svelte-highlight": "^7.2.1",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.2.0"
},
"type": "module",
"dependencies": {
"@sentry/browser": "^7.66.0",
"@sentry/sveltekit": "^7.66.0"
}
}

View file

@ -1,6 +0,0 @@
dnburgess_templates, https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json
qballjos_templates, https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json
selfhostedpro_templates, https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json
technorabilia_templates, https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates-2.0.json
mikestraney_templates, https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json
xneo1_templates, https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json
1 dnburgess_templates https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json
2 qballjos_templates https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json
3 selfhostedpro_templates https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json
4 technorabilia_templates https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates-2.0.json
5 mikestraney_templates https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json
6 xneo1_templates https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json

View file

View file

@ -1,4 +0,0 @@
{
"version": "2",
"templates": []
}

102
src/Types.ts Normal file
View file

@ -0,0 +1,102 @@
export interface PortainerAppTemplate {
version: string;
templates: Template[];
}
export interface Template {
type: 1 | 2 | 3; // 1 = Container, 2 = Swarm stack, 3 = Compose stack
title: string;
description: string;
categories: string[];
platform: string;
command?: string;
interactive?: boolean;
logo: string;
image?: string;
restart_policy?: 'always' | 'unless-stopped' | 'on-failure' | 'no';
ports?: string[];
volumes?: Volume[];
env?: Environment[];
repository?: {
stackfile: string;
url: string;
};
}
export interface Volume {
bind: string;
container: string;
readonly?: boolean;
}
export interface Environment {
name: string;
value?: string;
label?: string;
set?: string;
}
export interface Service {
name: string;
image?: string;
entrypoint?: string;
restart_policy?: 'always' | 'unless-stopped' | 'on-failure' | 'no';
volumes?: Volume[];
command?: string;
ports?: string[];
build?: string;
interactive?: boolean;
env?: Environment[];
dockerStats?: DockerHubResponse;
}
export interface TemplateOrService extends Template, Service {}
export interface DockerHubResponse {
user: string; // The user who owns the repository
name: string; // The name of the repository
namespace: string; // The namespace the repository belongs to
repository_type: string; // The type of repository (e.g., 'image')
status: number; // The status of the repository as a number
status_description: 'active' | 'inactive'; // Description of the repository status
description: string; // A brief description of the repository
is_private: boolean; // Whether the repository is private or not
is_automated: boolean; // Whether the repository is automated or not
star_count: number; // The number of stars the repository has received
pull_count: number; // The number of times the repository has been pulled
last_updated: string; // The date and time the repository was last updated
date_registered: string; // The date and time the repository was registered
collaborator_count: number; // The number of collaborators on the repository
affiliation?: string | null; // The affiliation of the user with the repo
hub_user: string; // The user who created the repository on Docker Hub
has_starred: boolean; // Whether the user has starred the repository or not
full_description: string; // The full description of the repository
permissions: {
read: boolean; // Whether the user has read permissions on the repository
write: boolean; // Whether the user has write permissions on the repository
admin: boolean; // Whether the user has admin permissions on the repository
};
media_types: string[]; // An array of supported media types for the repository
content_types: string[]; // An array of supported content types for the repository
}
export interface DockerCompose {
version: string;
services: {
[serviceName: string]: {
image: string;
ports?: string[];
environment?: { [envVar: string]: string };
volumes?: string[];
restart?: string;
command?: string;
build?: string | { context: string; dockerfile?: string };
networks?: string[] | { [networkName: string]: { aliases?: string[] } };
depends_on?: string[];
labels?: { [labelName: string]: string };
};
};
networks?: { [networkName: string]: {} };
volumes?: { [volumeName: string]: {} };
}

12
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};

30
src/app.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<title>Portainer Templates</title>
<link rel="manifest" href="manifest.json" />
<script defer data-domain="portainer-templates.as93.net" src="https://no-track.as93.net/js/script.js"></script>
<!-- Social Meta Tags -->
<meta name="title" content="Portainer Templates">
<meta name="description" content="A community-driven library of 1-click self-hosted apps">
<meta property="og:type" content="website">
<meta property="og:url" content="https://portainer-templates.as93.net">
<meta property="og:title" content="Portainer Templates">
<meta property="og:description" content="A community-driven library 400+ of 1-click self-hosted apps and stacks, for easy use with Portainer or Docker-Compose">
<meta property="og:image" content="https://portainer-templates.as93.net/banner.png">
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://portainer-templates.as93.net">
<meta property="twitter:title" content="Portainer Templates">
<meta property="twitter:description" content="A community-driven library 400+ of 1-click self-hosted apps and stacks, for easy use with Portainer or Docker-Compose">
<meta property="twitter:image" content="https://portainer-templates.as93.net/banner.png">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

6
src/constants.ts Normal file
View file

@ -0,0 +1,6 @@
export const templatesUrl = 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json';
export const baseUrl = 'https://portainer-templates.as93.net';
export const gitHubRepo = 'https://github.com/lissy93/portainer-templates';

12
src/hooks.client.ts Normal file
View file

@ -0,0 +1,12 @@
import { handleErrorWithSentry, Replay } from "@sentry/sveltekit";
import * as Sentry from '@sentry/sveltekit';
Sentry.init({
dsn: 'https://400f8ec8eaab4315bcda4f150e04f4fc@glitch.as93.net/2',
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [new Replay()],
});
export const handleError = handleErrorWithSentry();

12
src/hooks.server.ts Normal file
View file

@ -0,0 +1,12 @@
import { sequence } from "@sveltejs/kit/hooks";
import { handleErrorWithSentry, sentryHandle } from "@sentry/sveltekit";
import * as Sentry from '@sentry/sveltekit';
Sentry.init({
dsn: 'https://400f8ec8eaab4315bcda4f150e04f4fc@glitch.as93.net/2',
tracesSampleRate: 1.0,
});
export const handle = sequence(sentryHandle());
export const handleError = handleErrorWithSentry();

55
src/lib/Button.svelte Normal file
View file

@ -0,0 +1,55 @@
<script lang="ts">
import Icon from '$lib/Icon.svelte';
export let to = '';
export let action = () => {};
export let target = '_self';
export let icon: string | null = null;
export let selected: boolean = false;
</script>
<svelte:element this={to ? 'a' : 'button'} href={to} on:click={action} {target} class:selected>
{#if icon}<Icon name={icon} />{/if}
<slot />
</svelte:element>
<style lang="scss">
a, button {
position: relative;
color: var(--foreground);
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: transform 200ms ease-in-out;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid transparent;
background: var(--card);
cursor: pointer;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--gradient);
border-radius: 6px;
z-index: -1;
opacity: 0;
transition: opacity 300ms ease-in-out;
}
&:hover, &.selected {
transform: scale(1.05);
&::before {
opacity: 1;
}
}
}
</style>

33
src/lib/Categories.svelte Normal file
View file

@ -0,0 +1,33 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import Button from '$lib/Button.svelte';
export let categories: string[];
export let selectedCategories: string[];
export let toggleCategory: (category: string) => void;
const isSelected = (selected: string[], current: string) => selected.map((c) => c.toLocaleLowerCase()).includes(current.toLocaleLowerCase());
</script>
<div class="categories" transition:slide>
{#each Object.keys(categories) as category}
<Button
action={() => toggleCategory(category)}
selected="{isSelected(selectedCategories, category)}"
>
{category}
</Button>
{/each}
</div>
<style lang="scss">
.categories {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 1rem auto;
padding: 0 1rem;
gap: 0.25rem;
max-width: var(--max-width);
}
</style>

View file

@ -0,0 +1,85 @@
<script lang="ts">
import type { DockerHubResponse } from '$src/Types';
import Icon from '$lib/Icon.svelte';
export let info: DockerHubResponse;
const formatBigNumber = (num: number): string => {
if (!num) return '';
const units = ['k', 'M', 'B'];
let unitIndex = 0;
let value = num;
while (value >= 1000 && unitIndex < units.length) {
value /= 1000;
unitIndex++;
}
const decimalPlaces = num < 10000 || (num >= 100000 && num < 1000000) ? 0 : 1;
return num < 1000 ? num.toString() : value.toFixed(decimalPlaces) + units[unitIndex - 1];
};
const formatDate = (dateTime: string): string => {
if (!dateTime) return '';
const date = new Date(dateTime);
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
}).format(date);
};
const timeAgo = (dateTime: string): string => {
if (!dateTime) return '';
const elapsed = Date.now() - new Date(dateTime).getTime();
const msPer = [60000, 3600000, 86400000, 2592000000, 31536000000];
const units = ['minute', 'hour', 'day', 'month', 'year'];
for (let i = 0; i < msPer.length; i++) {
if (elapsed < msPer[i]) {
const value = Math.floor(elapsed / (i > 0 ? msPer[i - 1] : 1));
return value === 0 ? 'just now' : `${value} ${units[i - 1] || 'minute'}${value > 1 ? 's' : ''} ago`;
}
}
return `${Math.floor(elapsed / msPer[4])} years ago`;
};
const makeRenderData = () => {
const results = [
{ label: 'Pulls', value: formatBigNumber(info.pull_count), icon: 'download' },
{ label: 'Stars', value: formatBigNumber(info.star_count) || 'None yet', icon: 'star' },
{ label: 'User', value: info.hub_user, icon: 'user' },
{ label: 'Created', value: formatDate(info.date_registered), icon: 'published' },
{ label: 'Updated', value: timeAgo(info.last_updated), icon: 'updated' },
{ label: 'Status', value: info.status_description, icon: 'status' }
];
return results;
};
</script>
<div class="stats">
{#each makeRenderData() as stat}
<div class="row">
<span class="lbl">
<Icon name={stat.icon} color="var(--accent)" />
{stat.label}:
</span>
<span>{stat.value}</span>
</div>
{/each}
</div>
<style lang="scss">
.stats {
background: var(--card-2);
padding: 1rem;
border-radius: 6px;
.lbl {
font-weight: 500;
margin-right: 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
:global(svg) { opacity: 0.7; }
}
}
</style>

57
src/lib/Footer.svelte Normal file
View file

@ -0,0 +1,57 @@
<script lang="ts">
import Icon from '$lib/Icon.svelte';
export let bottom = false;
let footerInfo = {
author: 'Alicia Sykes',
authorSite: 'https://github.com/lissy93',
license: 'MIT',
licenseLink: 'https://gist.github.com/Lissy93/143d2ee01ccc5c052a17',
copyright: true,
source: 'https://github.com/lissy93/portainer-templates',
};
</script>
<footer class:bottom>
<p>
© <a href={footerInfo.authorSite} target="_blank" rel="noreferrer">{footerInfo.author}</a>
{new Date().getFullYear()} - Licensed under
<a href={footerInfo.licenseLink} target="_blank" rel="noreferrer">{footerInfo.license}</a> -
View on <a href={footerInfo.source} target="_blank" rel="noreferrer">
GitHub <Icon name="github" color="var(--accent)" /></a>
</p>
</footer>
<style lang="scss">
footer {
bottom: 0;
padding: 0.5rem 0;
width: 100%;
background: var(--card);
&.bottom {
box-shadow: 0 -3px 4px 0 var(--background);
position: fixed;
}
p {
margin: 0;
text-align: center;
a {
color: var(--accent);
border-radius: 4px;
padding: 0.1rem 0.25rem;
text-decoration: none;
display: inline-flex;
flex-direction: revert;
gap: 0.25rem;
align-items: center;
&:hover {
background: var(--accent);
color: var(--background);
:global(svg) {
fill: var(--background);
}
}
}
}
}
</style>

56
src/lib/Header.svelte Normal file
View file

@ -0,0 +1,56 @@
<script lang="ts">
import Button from '$lib/Button.svelte';
import { gitHubRepo } from '$src/constants';
</script>
<header>
<a class="title" href="/">
<img src="https://i.ibb.co/hMymwH0/portainer-templates-small.png" />
<h2>Portainer Templates</h2>
</a>
<nav>
<Button to="/" icon="whale">All Templates</Button>
<Button to={gitHubRepo} icon="github">GitHub</Button>
</nav>
</header>
<style lang="scss">
header {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--card);
padding: 0.25rem 1rem;
flex-wrap: wrap;
a.title {
display: flex;
justify-content: center;
gap: 1rem;
color: var(--foreground);
text-decoration: none;
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
img {
width: 40px;
transition: all 0.3s ease-in-out;
}
&:hover {
img { transform: rotate(-5deg) scale(1.1); }
}
}
nav {
display: flex;
gap: 1rem;
}
&.fixed {
position: fixed;
top: 0;
width: calc(100% - 2rem);
box-shadow: 0 3px 4px 0 var(--background);
}
}
</style>

89
src/lib/Hero.svelte Normal file
View file

@ -0,0 +1,89 @@
<script>
import Icon from '$lib/Icon.svelte';
import { gitHubRepo } from '$src/constants';
</script>
<div class="hero">
<header>
<h1>Portainer Templates</h1>
<p class="sub-title">The largest single collection, of ready-to-go Portainer templates</p>
</header>
<section class="cta">
<a href={gitHubRepo}>
<Icon name="github" width="26px" height="26px" />
View on GitHub
</a>
<a href="/usage">
<Icon name="portainer" width="26px" height="26px" />
Install on Portainer
</a>
</section>
</div>
<style lang="scss">
.hero {
padding: 2rem;
header {
h1 {
text-align: center;
font-size: 4rem;
margin: 0 auto;
background: var(--gradient);
background-clip: border-box;
-moz-background-clip: text;
-webkit-background-clip: text;
background-clip: text;
-moz-text-fill-color: transparent;
-webkit-text-fill-color: transparent;
color: transparent;
}
.sub-title {
text-align: center;
margin: 0 auto;
font-size: 1.4rem;
font-style: italic;
font-weight: 200;
}
}
section.cta {
margin: 1rem auto;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
a {
font-size: 1.2rem;
transition:all 0.3s ease-in-out;
position: relative;
border-radius: 6px;
background: var(--background);
background-clip: padding-box;
padding: 0.5rem 1rem;
cursor: pointer;
display: flex;
gap: 1rem;
align-items: center;
min-width: 13rem;
color: var(--foreground);
text-decoration: none;
&::after {
position: absolute;
top: -4px; bottom: -4px;
left: -4px; right: -4px;
background: var(--gradient);
content: '';
z-index: -1;
border-radius: 6px;
}
&:hover {
background: var(--gradient);
transform: scale(1.03) rotate(-0.6deg);
}
}
}
}
</style>

145
src/lib/Icon.svelte Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,222 @@
<script lang="ts">
import Highlight from "svelte-highlight";
import yamlHighlight from "svelte-highlight/languages/yaml";
import shellHighlight from "svelte-highlight/languages/shell";
import codeHighlighting from "svelte-highlight/styles/dracula";
import {
generateDockerRunCommand,
generateDockerRunCommands,
convertToDockerCompose,
convertPortainerStackToDockerCompose,
} from '$src/utils/template-to-docker-parser';
import { templatesUrl, gitHubRepo } from '$src/constants';
import type { Template, Volume, Service, DockerCompose } from '$src/Types';
export let portainerTemplate: Template | null = null;
export let portainerServices: Service[] | null = null;
const copyToClipboard = (content: string) => {
navigator.clipboard.writeText(content);
};
const dockerRunCommand = portainerTemplate?.image ?
generateDockerRunCommand(portainerTemplate) : null;
const dockerRunCommands = portainerServices && !dockerRunCommand ?
generateDockerRunCommands(portainerServices) : null;
const dockerComposeFile = portainerTemplate?.image ?
convertToDockerCompose(portainerTemplate) :
(portainerServices ? convertPortainerStackToDockerCompose(portainerServices) : null);
</script>
<svelte:head>
{@html codeHighlighting}
</svelte:head>
<section>
<h2>Installation</h2>
<h3>Via Portainer</h3>
<ol>
<li>
Ensure both
<a href="https://docs.docker.com/engine/install/">Docker</a> and
<a href="https://www.portainer.io/installation/">Portainer</a> are installed, and up-to-date
</li>
<li>Log into your Portainer web UI
<li>Under Settings → App Templates, paste the below URL</li>
<li>Head to Home → App Templates, and the list of apps will show up</li>
<li>Select the app you wish to deploy, fill in any config options, and hit Deploy</li>
</ol>
<h4>Template Import URL</h4>
<pre class="template-url">{templatesUrl}</pre>
<button on:click={() => copyToClipboard(templatesUrl)}>Copy</button>
<details>
<summary>Show Me</summary>
<img class="demo" src="https://i.ibb.co/XxGRjrs/portainer-templates-installation.gif" alt="demo" />
</details>
{#if dockerRunCommand}
<hr />
<h3>Via Docker Run</h3>
<div class="docker-run-command">
<button class="docker-command-copy" on:click={() => copyToClipboard(dockerRunCommand)}>Copy</button>
<Highlight language={shellHighlight} code={dockerRunCommand} />
</div>
{/if}
{#if dockerRunCommands && dockerRunCommands.length > 0}
<hr />
<h3>Via Docker Run</h3>
{#each dockerRunCommands as command, index}
<h4>Service #{index + 1} - {portainerServices[index].name}</h4>
<div class="docker-run-command">
<button class="docker-command-copy" on:click={() => copyToClipboard(command)}>Copy</button>
<Highlight language={shellHighlight} code={command} />
</div>
{/each}
{/if}
{#if dockerComposeFile}
<hr />
<h3>Via Docker Compose</h3>
<p class="instructions">
Save this file as <code>docker-compose.yml</code> and run <code>docker-compose up -d</code>
<br>
Use this only as a guide.
</p>
<div class="docker-compose-file">
<button class="docker-command-copy" on:click={() => copyToClipboard(JSON.stringify(dockerComposeFile, null, 2))}>Copy</button>
<Highlight language={yamlHighlight} code={dockerComposeFile} />
</div>
{/if}
<hr />
<h3>Alternative Methods</h3>
<p>For more installation options, see the <a href={gitHubRepo}>Documentation</a> in the GitHub repo</p>
</section>
<style lang="scss">
section {
background: var(--card);
padding: 1rem;
border-radius: 6px;
margin: 1rem auto;
max-width: 1000px;
transition: all 0.2s ease-in-out;
h2 {
margin: 0;
font-size: 2rem;
}
h3 {
font-size: 1.5rem;
margin: 0.5rem 0;
}
h4 {
margin: 0.5rem 0;
}
p {
margin: 0;
}
ol {
margin: 0.5rem;
padding: 0;
list-style: none;
li {
counter-increment: item;
}
li:before {
content: counter(item);
color: var(--accent);
margin-right: 0.5rem;
font-weight: 600;
width: 1ch;
text-align: center;
display: inline-block;
}
}
hr {
opacity: 0.5;
margin: 1.5rem auto;
height: 2px;
border: none;
background: var(--accent);
}
pre {
background: var(--card-2);
padding: 0.25rem 0.5rem;
font-size: 1.1rem;
width: fit-content;
margin: 0.5rem 0;
display: inline;
border-radius: 6px;
&.template-url {
white-space: normal;
}
}
button {
background: var(--background);
padding: 0.25rem 0.5rem;
border-radius: 6px;
border: none;
color: var(--foreground);
font-family: Kanit;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
background: var(--gradient);
transform: scale(1.1) rotate(-1deg);
}
}
a {
color: var(--accent);
}
details {
summary {
cursor: pointer;
font-weight: bold;
&:hover {
color: var(--accent);
}
}
}
.demo {
display: block;
margin: 0.5rem auto;
border-radius: 6px;
max-width: 50rem;
}
.docker-run-command, .docker-compose-file {
background: var(--card-2);
position: relative;
padding: 0.5rem;
pre {
font-size: 1rem;
}
.docker-command-copy {
position: absolute;
right: 0.5rem;
top: 0.5rem;
}
}
.instructions {
margin-bottom: 0.5rem;
font-size: 1rem;
code {
border-radius: 6px;
padding: 0 0.25rem;
background: var(--card-2);
}
}
:global(.hljs) {
background: var(--card-2);
font-size: 1.1rem;
padding: 0;
}
}
</style>

60
src/lib/ListFilter.svelte Normal file
View file

@ -0,0 +1,60 @@
<script lang="ts">
export let searchTerm: string;
export let isCategoriesVisible: boolean;
export let toggleCategories: () => void;
</script>
<div class="title-row">
<h2>Template List</h2>
<div class="filters">
<button on:click={toggleCategories}>
{isCategoriesVisible ? '▲' : '▼'} Categories
</button>
<input type="text" placeholder="Search..." bind:value={searchTerm} />
</div>
</div>
<style lang="scss">
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 1rem auto;
padding: 0 1rem;
max-width: var(--max-width);
flex-wrap: wrap;
h2 {
font-size: 2rem;
margin: 0;
}
.filters {
input {
background: var(--card);
border: 1px solid transparent;
color: var(--foreground);
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition:all 0.3s ease-in-out;
&:focus, &:hover {
box-shadow: var(--shadow);
}
}
}
button {
color: var(--foreground);
border: 1px solid transparent;
padding: 0 0.3rem;
margin: 0.25rem;
line-height: 2rem;
border-radius: 6px;
text-transform: capitalize;
background: var(--card);
transition: all 0.3s ease-in-out;
cursor: pointer;
font-size: 0.9rem;
&:hover, &.selected {
background: var(--gradient);
}
}
}
</style>

85
src/lib/MdContent.svelte Normal file
View file

@ -0,0 +1,85 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import snarkdown from 'snarkdown';
export let content: string | null = null;
export let multiContent: { name: string, content: string, description: string, visible: false }[] | null = null;
let showDocs = false;
const toggleDocs = () => {
showDocs = !showDocs;
};
</script>
<section class="docker-docs">
<h2>Container Documentation</h2>
{#if content}
<button on:click={toggleDocs}>{ showDocs ? 'Hide' : 'Expand' } Content</button>
{#if showDocs}
<p transition:slide>{@html snarkdown(content)}</p>
{/if}
{:else if multiContent && multiContent.length > 0}
{#each multiContent as { name, description, content, visible }}
<h3>{name} Documentation</h3>
<p class="desc">{description || ''}</p>
<button on:click={() => visible = !visible}>{ visible ? 'Hide' : 'Expand' } {name}</button>
{#if visible}
<p transition:slide>{@html snarkdown(content)}</p>
{/if}
{/each}
{/if}
</section>
<style lang="scss">
.docker-docs {
background: var(--card);
padding: 1rem;
border-radius: 6px;
margin: 1rem auto;
max-width: 1000px;
transition: all 0.2s ease-in-out;
button {
background: var(--background);
padding: 0.25rem 0.5rem;
border-radius: 6px;
border: none;
color: var(--foreground);
font-family: Kanit;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
background: var(--gradient);
transform: scale(1.1) rotate(-1deg);
}
}
h2 {
font-size: 2rem;
margin: 0;
}
h3 {
margin: 0.5rem 0;
text-transform: capitalize;
}
.desc {
opacity: 0.7;
margin: 0.5rem 0;
font-style: italic;
}
:global(img) {
max-width: 100%;
}
:global(a) {
color: var(--accent);
text-decoration: none;
}
:global(pre) {
background: var(--card-2);
padding: 1rem;
border-radius: 6px;
overflow: auto;
}
}
</style>

36
src/lib/NoResults.svelte Normal file
View file

@ -0,0 +1,36 @@
<div class="nout">
<h3>No Results 😢</h3>
<p>
<i>There weren't any templates found that matched the currently applied filters.</i><br><br>
Check the raw <a href="https://github.com/Lissy93/portainer-templates/blob/main/templates.json"><code>templates.json</code></a> file
to see all results.
If you still can't find what you're looking for, why not
<a href="https://github.com/Lissy93/portainer-templates#editing">submit a template</a>?
Feel free to raise a ticket if you need support.
</p>
</div>
<style lang="scss">
.nout {
background: var(--card);
border-radius: 6px;
min-height: 8rem;
margin: 1rem auto 5rem auto;
padding: 1rem;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
max-width: 650px;
h3 {
margin: 0;
font-size: 3rem;
}
p {
font-size: 1.1rem;
code, a {
color: var(--accent);
}
}
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
export let searchTerm: string;
export let selectedCategories: string[];
export let clearSearch: () => void;
export let numResults: number;
export let totalResults: number;
</script>
<div class="search-summary">
{#if searchTerm}
<p>
Showing {numResults} of {totalResults}
results, matching "<i>{searchTerm}</i>"
{selectedCategories.length ? `in categories: ${selectedCategories.join(', ')}` : ''}
</p>
{:else if selectedCategories.length}
<p>
Showing {numResults} of {totalResults}
results, matching categories: {selectedCategories.join(', ')}
</p>
{:else}
<p>Click an app to view info, stats and usage docs</p>
{/if}
{#if searchTerm || selectedCategories.length}
<button on:click={clearSearch}> Clear Filters</button>
{/if}
</div>
<style lang="scss">
.search-summary {
margin: 0 1rem;
font-size: 0.9rem;
display: flex;
gap: 1rem;
align-items: center;
p {
opacity: 0.75;
margin: 0;
}
button {
background: var(--gradient);
outline: none;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 6px;
color: var(--foreground);
font-weight: 800;
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
transform: scale(1.1);
}
}
}
</style>

113
src/lib/ServiceStats.svelte Normal file
View file

@ -0,0 +1,113 @@
<script lang="ts">
import type { TemplateOrService } from '$src/Types';
export let template: TemplateOrService;
</script>
<div class="stats">
{#if template.type}
<span class="lbl">Type</span>
{#if template.type === 1}
<span class="val">Container</span>
{:else if template.type === 2}
<span class="val">Swarm</span>
{:else if template.type === 3}
<span class="val">Kubernetes</span>
{:else}
<span class="val">Unknown</span>
{/if}
{/if}
{#if template.platform}
<span class="lbl">Platform</span>
<code class="val">{template.platform}</code>
{/if}
{#if template.image}
<span class="lbl">Image</span>
<code class="val">{template.image}</code>
{/if}
{#if template.command}
<span class="lbl">Command</span>
<code class="val">{template.command}</code>
{/if}
{#if typeof template.interactive === 'boolean'}
<span class="lbl">Interactive</span>
<code class="val">{template.interactive ? 'Yes' : 'No'}</code>
{/if}
{#if template.ports}
<span class="lbl">Ports</span>
<p class="val">
{#each template.ports as port}<code>{port}</code>{/each}
</p>
{/if}
{#if template.volumes}
<span class="lbl">Volumes</span>
<p class="val">
{#each template.volumes as volume}
<code>
{volume.container || volume}{volume?.bind? ' : ' + volume.bind : ''}
</code>{/each}
</p>
{/if}
{#if template.restart_policy}
<span class="lbl">Restart Policy</span>
<code class="val">{template.restart_policy}</code>
{/if}
{#if template.repository}
<span class="lbl">Sourced</span>
<a class="val" href={template.repository.url}>Repo</a>
{/if}
{#if template.entrypoint}
<span class="lbl">Entrypoint</span>
<code class="val">{template.entrypoint}</code>
{/if}
{#if template.build}
<span class="lbl">Build</span>
<code class="val">{template.build}</code>
{/if}
{#if template.env}
<span class="lbl">Env Vars</span>
<p class="val">
{#each template.env as env}<code>{env.name}={env.set || env.value || env.default || '\'\''}</code>{/each}
</p>
{/if}
</div>
<style lang="scss">
.stats {
min-width: 15rem;
padding: 0.5rem;
gap: 0.5rem;
border-radius: 6px;
display: grid;
grid-template-columns: 1fr auto;
place-items: baseline;
background: var(--card-2);
.lbl {
font-weight: 400;
font-style: normal;
}
.val {
max-width: 10rem;
overflow: hidden;
white-space:nowrap;
text-overflow: ellipsis;
}
span {
font-style: italic;
}
p {
margin: 0;
display: flex;
flex-direction: column;
}
a {
color: var(--accent);
}
}
</style>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import type { Template } from '$src/Types';
export let templates: Template[];
import { lazyLoad } from '$lib/lazy-load';
const slugify = (title: string) => {
return `/${title.toLowerCase().replace(/[^a-zA-Z ]/g, "").replaceAll(' ', '-')}`;
}
</script>
<section class="templates">
{#each templates as template (template.title)}
<a class="template-card" href={slugify(template.title)}>
<h3>{template.title}</h3>
<div class="template-summary">
<div class="left">
<img class="loading" use:lazyLoad={template.logo} alt={template.title} />
</div>
<div class="txt">
<p class="description" title={template.description}>{template.description}</p>
</div>
</div>
</a>
{/each}
</section>
<style lang="scss">
section.templates {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin: 1rem auto;
padding: 0 1rem;
max-width: var(--max-width);
.template-card {
padding: 1rem;
border-radius: 6px;
background: var(--card);
display: flex;
flex-direction: column;
gap: 1rem;
transition:all 0.3s ease-in-out;
max-width: 28rem;
text-decoration: none;
color: var(--foreground);
&:hover {
box-shadow: var(--shadow);
}
.template-summary {
display: flex;
gap: 1rem;
align-items: start;
}
p, h3 {
margin: 0;
}
img {
width: 64px;
max-height: 64px;
border-radius: 6px;
&.loading {
padding: 0.2rem;
background: var(--card-2);
border-radius: 6px;
height: 64px;
}
}
.description {
font-style: italic;
font-weight: 200;
overflow: hidden;
word-break: break-word;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
line-clamp: 5;
}
}
}
</style>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { gitHubRepo } from '$src/constants';
export let templateName: string;
</script>
<section>
<h2>Template not Found 😢</h2>
<p class="subtitle">It doesn't look like there was a templated named "<i>{templateName}</i>"</p>
<p>
You can try <a href="/">searching for another</a>, or if you think there's a mistake somewhere,
please open an issue on the <a href={gitHubRepo} target="_blank">Github Repo</a>.
</p>
<a class="back-home" href="/">Back Home</a>
</section>
<style lang="scss">
section {
background: var(--card);
padding: 1rem;
border-radius: 6px;
margin: 1rem auto;
max-width: 1000px;
transition: all 0.2s ease-in-out;
h2 {
margin: 0;
font-size: 3rem;
text-align: center;
}
p {
margin: 1rem auto;
font-size: 1.1rem;
opacity: 0.8;
text-align: center;
max-width: 40rem;
a {
color: var(--accent);
}
}
.back-home {
background: var(--background);
padding: 0.25rem 0.5rem;
margin: 0 auto;
display: block;
width: fit-content;
border-radius: 6px;
border: none;
color: var(--foreground);
font-family: Kanit;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.2s ease-in-out;
text-decoration: none;
&:hover {
background: var(--gradient);
transform: scale(1.1) rotate(-1deg);
}
}
}
</style>

34
src/lib/lazy-load.ts Normal file
View file

@ -0,0 +1,34 @@
// See how the options work here: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
let options = {
root: null,
rootMargin: "0px",
threshold: 0
}
export const lazyLoad = (image: any, src: string) => {
const loaded = () => {
image.classList.remove("loading");
image.style.opacity = "1";
};
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
image.src = src;
if (image.complete) {
loaded();
} else {
image.addEventListener("load", loaded);
}
}
},
options
);
observer.observe(image);
return {
destroy() {
observer.disconnect();
image.removeEventListener("load", loaded);
},
};
};

78
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,78 @@
<script lang="ts">
import { browser } from '$app/environment';
import { page, navigating } from '$app/stores';
import { tick } from 'svelte';
import Header from '$lib/Header.svelte';
import Footer from '$lib/Footer.svelte';
let bottom = false;
let showNav = false;
const scrollVisible = (): boolean => {
return browser ?
document.documentElement.clientHeight >= document.documentElement.scrollHeight
: false;
};
$: {
updateFooter();
if($navigating) updateFooter();
showNav = !['/', '/index'].includes($page.url.pathname)
}
async function updateFooter() {
await tick();
bottom = scrollVisible();
}
</script>
<svelte:head>
<title>Portainer Templates</title>
<meta name="description" content="A community-driven library of 1-click self-hosted apps" />
<meta property="og:title" content="Portainer Templates" />
<meta property="og:description" content="A community-driven library of 1-click self-hosted apps" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{import.meta.env.VITE_PUBLIC_BASE_URL}/" />
<meta property="og:image" content="{import.meta.env.VITE_PUBLIC_BASE_URL}/banner.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Portainer Templates" />
<meta name="twitter:description" content="A community-driven library of 1-click self-hosted apps" />
<meta name="twitter:image" content="{import.meta.env.VITE_PUBLIC_BASE_URL}/banner.png" />
<link rel="canonical" href="{import.meta.env.VITE_PUBLIC_BASE_URL}" />
<meta name="theme-color" content="#0ba5ec" />
</svelte:head>
{#if showNav}
<Header />
{/if}
<main>
<slot></slot>
</main>
<Footer {bottom} />
<style lang="scss">
@import url('https://fonts.googleapis.com/css2?family=Kanit:wght@200;400;800&display=swap');
:global(body) {
--background: #101828;
--foreground: #ffffff;
--accent: #0ba5ec;
--card: #1d2939;
--card-2: #192432;
--shadow: 1px 1px 3px 3px #0B9AEC8F;
--gradient: linear-gradient(to right,#0B9AEC 0%,#6EDFDE 100%);
--max-width: 1800px;
margin: 0;
font-family: 'Kanit', sans-serif;
color: var(--foreground);
background: var(--background);
}
:global(::selection) {
background: var(--accent);
color: var(--background);
}
main {
padding: 2rem;
}
</style>

View file

@ -0,0 +1,32 @@
import { templates } from '$src/store';
import { templatesUrl } from '$src/constants';
const makeCategories = (templates) => {
// Get categories from templates
const categories = templates.reduce((acc, { categories: templateCategories }) => {
(templateCategories || []).forEach((category) => {
acc[category] = (acc[category] || 0) + 1;
});
return acc;
}, {});
// Sort categories by count, and remove categories with only 1 template
const sortedCategories = Object.fromEntries(
Object.entries(categories)
.filter(([, value]) => value > 3)
.sort(([, a], [, b]) => b - a)
);
return sortedCategories;
};
export const load = async () => {
const data = await fetch(templatesUrl).then((res) => res.json());
templates.set(data.templates);
return {
templates: data.templates,
categories: makeCategories(data.templates),
}
};

97
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,97 @@
<script lang="ts">
import { page } from '$app/stores'
import Hero from '$lib/Hero.svelte';
import ListFilter from '$lib/ListFilter.svelte';
import Categories from '$lib/Categories.svelte';
import SearchSummary from '$lib/SearchSummary.svelte';
import Templates from '$lib/TemplateList.svelte';
import NoResults from '$lib/NoResults.svelte';
import Footer from '$lib/Footer.svelte';
import type { Template } from '$src/Types';
export let data;
const preSelectedCategories = $page.url.searchParams.get('categories');
let searchTerm = '';
let selectedCategories: string[] = preSelectedCategories?.split(',') || [];
let showCategories = !!preSelectedCategories || false;
$: filteredTemplates = data.templates.filter((template: Template) => {
const compareStr = (str1: string, str2: string) =>
(str1 || '').toLowerCase().includes(str2.toLowerCase());
if (selectedCategories.length) {
const templateCategories = (template.categories || []).map((c) => c.toLowerCase());
const hasSelectedCategory = selectedCategories.some((cat) =>
templateCategories.includes(cat.toLocaleLowerCase())
);
if (!hasSelectedCategory) return false;
}
return (
compareStr(template.title, searchTerm) ||
compareStr(template.description, searchTerm) ||
compareStr((template.categories || []).join(''), searchTerm)
);
});
const showHideCategoryList = () => {
showCategories = !showCategories;
};
const toggleCategory = (category: string) => {
if (selectedCategories.includes(category)) {
selectedCategories = selectedCategories.filter((cat) => cat !== category);
} else {
selectedCategories = [...selectedCategories, category];
}
};
const clearSearch = () => {
searchTerm = '';
selectedCategories = [];
}
</script>
<!-- Main title, and CTA buttons -->
<Hero />
<!-- Search bar, and Templates sub-title -->
<ListFilter
bind:searchTerm={searchTerm}
toggleCategories={showHideCategoryList}
isCategoriesVisible={showCategories}
/>
<!-- List of categories to filter by -->
{#if showCategories}
<Categories
categories={data.categories}
selectedCategories={selectedCategories}
toggleCategory={toggleCategory}
/>
{/if}
<!-- Text showing num results, and users search term + filters -->
<SearchSummary
searchTerm={searchTerm}
selectedCategories={selectedCategories}
clearSearch={clearSearch}
numResults={filteredTemplates.length}
totalResults={data.templates.length}
/>
<!-- List of available templates (filtered, if needed) -->
<Templates templates={filteredTemplates} />
<!-- If there are no templates matching search term, show lil message -->
{#if !filteredTemplates.length}
<NoResults />
{/if}

View file

@ -0,0 +1,109 @@
import yaml from 'js-yaml';
import { get } from 'svelte/store';
import { templatesUrl } from '$src/constants';
import { templates } from '$src/store';
/* Based on the current page name, find the corresponding template */
const findTemplate = (templates: any, slug: string) => {
return templates.find((temp: Template) =>
temp.title.toLowerCase().replace(/[^a-zA-Z ]/g, "").replaceAll(' ', '-') === slug
);
};
/* With a given image name, fetch stats from DockerHub registry */
const getDockerHubStats = async (image: string): Promise<DockerHubResponse | null> => {
if (!image) return null;
const [imageName, tag] = image.split(':');
const [namespace, repo] = imageName.includes('/') ? imageName.split('/') : ['library', imageName];
const apiEndpoint = `https://hub.docker.com/v2/repositories/${namespace}/${repo}/`;
return await fetch(apiEndpoint)
.then((res) => res.json())
.then((data) => {
return data;
})
.catch((err) => {
return null;
});
}
const getServices = async (template): Promise<Service[]> => {
try {
if (template?.repository) {
const { url: repoUrl, stackfile } = template.repository;
const path = `${repoUrl.replace('github.com', 'raw.githubusercontent.com')}/HEAD/${stackfile}`;
const response = await fetch(path);
const data = await response.text();
const parsedData = yaml.load(data);
const someServices: Service[] = [];
if (!parsedData.services) return [];
Object.keys(parsedData.services).forEach((service) => {
const serviceData = parsedData.services[service];
someServices.push({
name: service,
image: serviceData.image,
entrypoint: serviceData.entrypoint,
command: serviceData.command,
ports: serviceData.ports,
build: serviceData.build,
interactive: serviceData.interactive,
volumes: serviceData.volumes?.map((vol) => ({
bind: vol.split(':')[0],
container: vol.split(':')[1],
})),
restart_policy: serviceData.restart,
env: Object.keys(serviceData.environment || {}).map((envName) => {
if (typeof envName === 'string') {
const nowItsArray = serviceData.environment[envName].split('=') || [];
return { name: nowItsArray[0] || '', value: nowItsArray[1] || '' }
}
return { name: envName, value: serviceData.environment[envName] }
}),
});
});
return someServices;
} else {
return [];
}
} catch (error) {
console.error('Error fetching or parsing YAML:', error);
return [];
}
};
/* Format results for returning to component */
const returnResults = async (templates, templateSlug) => {
// Find template, based on slug
let template = findTemplate(get(templates), templateSlug);
// Fetch service info from associated stackfile, if it exists
let services = template?.repository ? await getServices(template) : [];
// If only 1 service, merge it with the template
if (services.length === 1) {
template = {...template, ...services[0]};
} else if (services.length > 1) {
// If made up from multiple services, fetch Docker info for each image
services = await Promise.all(
services.map(async (service) => {
const dockerStats = await getDockerHubStats(service.image);
return { ...service, dockerStats };
})
);
}
// If image specified, fetch Docker image info from DockerHub
const dockerStats = template?.image ? await getDockerHubStats(template.image) : null;
return { template, dockerStats, services }
};
export const load = async ({ params }) => {
const templateSlug = params.slug as string;
if (get(templates) && get(templates).length > 0) {
return returnResults(templates, templateSlug);
} else {
const data = await fetch(templatesUrl).then((res) => res.json());
templates.set(data.templates);
return returnResults(templates, templateSlug);
}
};

View file

@ -0,0 +1,202 @@
<script lang="ts">
import { page } from '$app/stores';
import ServiceStats from '$lib/ServiceStats.svelte';
import TemplateNotFound from '$lib/TemplateNotFound.svelte';
import DockerStats from '$lib/DockerStats.svelte';
import MdContent from '$lib/MdContent.svelte';
import InstallationInstructions from '$lib/InstallationInstructions.svelte';
import type { Template, Service, DockerHubResponse } from '$src/Types';
const urlSlug = $page.params.slug;
const template = $page.data.template as Template;
const dockerStats = $page.data.dockerStats as DockerHubResponse;
const services = $page.data.services as Service[];
const makeMultiDoc = (services: Service[]) => {
return services.map((s) => {
return s?.dockerStats?.full_description ? {
name: s.name,
description: s.dockerStats.description,
content: s.dockerStats.full_description,
visible: false,
} : null;
}).filter((thingy) => thingy !== null);
};
const makeMetaDescription = () => {
return `Installation guide for ${template.title}, using Portainer, Docker Run or Docker-Compose. `
+`Portainer-Templates is a community driven repository of Portainer Templates for Self-Hosted apps. \n`
+`${template.description}`;
}
</script>
<svelte:head>
<title>{template.title} | Portainer Templates</title>
<meta name="description" content={makeMetaDescription()} />
<meta property="og:title" content="{template.title} | Portainer Templates" />
<meta property="og:description" content={makeMetaDescription()} />
<meta property="og:url" content="{import.meta.env.VITE_PUBLIC_BASE_URL}/{urlSlug}" />
<meta name="twitter:title" content="{template.title} | Portainer Templates" />
<meta name="twitter:description" content={makeMetaDescription()} />
<link rel="canonical" href="{import.meta.env.VITE_PUBLIC_BASE_URL}/{urlSlug}" />
</svelte:head>
{#if template}
<section class="summary-section">
<h1>
{#if template.logo} <img src={template.logo} /> {/if}
{template.title}
</h1>
{#if template.categories || template.category }
<p class="tags">
{#each (template.categories || template.category || []) as tag}
<a href="/?categories={tag}"><span>{tag}</span></a>
{/each}
</p>
{/if}
<div class="content">
<div class="left">
<p class="description">{template.description}</p>
{#await template then returnedTemplate}
{#if dockerStats && dockerStats.name}
<DockerStats info={dockerStats} />
{/if}
{/await}
</div>
<ServiceStats template={template} />
</div>
</section>
{#await services then returnedServices}
{#if returnedServices && returnedServices.length > 1}
<section class="service-section">
<h2>Services</h2>
<div class="service-list">
{#each returnedServices as service}
<div class="service-each">
<h3>{service.name}</h3>
<div class="service-data">
<ServiceStats template={service} />
{#if service.dockerStats && service.dockerStats.name}
<DockerStats info={service.dockerStats} />
{/if}
</div>
</div>
{/each}
</div>
</section>
{/if}
{/await}
<InstallationInstructions portainerTemplate={template} portainerServices={services || null} />
{#if dockerStats?.full_description}
<MdContent content={dockerStats.full_description} />
{:else if services.length > 0}
<MdContent multiContent={makeMultiDoc(services)} />
{/if}
{:else}
<TemplateNotFound templateName={urlSlug} />
{/if}
<style lang="scss">
section {
max-width: 1000px;
margin: 1rem auto;
}
.summary-section {
background: var(--card);
border-radius: 6px;
padding: 1rem;
display: flex;
flex-direction: column;
h1 {
font-size: 4rem;
margin: 0;
display: flex;
align-items: center;
gap: 1rem;
}
img {
border-radius: 6px;
width: 64px;
max-height: 64px;
}
.tags {
display: flex;
margin: 0;
gap: 0.5rem;
a {
color: var(--foreground);
text-decoration: none;
transition: all 0.2s ease-in-out;
span {
&:before {
content: '#';
opacity: 0.5;
}
&:not(:last-child)::after {
content: ',';
margin-right: 0.5rem;
}
}
&:hover {
color: var(--accent);
transform: scale(1.08);
}
}
}
}
.content {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
margin-top: 1rem;
.left {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
p.description {
background: var(--card-2);
padding: 1rem;
border-radius: 6px;
margin: 0;
}
}
.service-section {
background: var(--card);
border-radius: 6px;
padding: 1rem;
h2 {
margin: 0;
font-size: 2rem;
}
.service-list {
display: flex;
gap: 2rem;
flex-wrap: wrap;
h3 {
margin: 0.5rem 0;
font-weight: 400;
font-size: 2rem;
}
.service-each {
.service-data {
display: flex;
gap: 1rem;
}
}
}
}
</style>

View file

@ -0,0 +1,41 @@
import type { RequestHandler } from '@sveltejs/kit';
import { templatesUrl, baseUrl } from '$src/constants';
import type { Template } from '$src/Types';
const fetchData = async () => {
const data = await fetch(templatesUrl).then((res) => res.json());
return await data.templates.map((d: Template) => `${baseUrl}/${d.title.toLowerCase().replace(/[^a-zA-Z ]/g, "").replaceAll(' ', '-')}`);
};
const generationDate = () => {
const date = new Date();
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
export async function GET() {
const data = await fetchData();
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${baseUrl}</loc>
<lastmod>${generationDate()}</lastmod>
<changefreq>weekly</changefreq>
<priority>1</priority>
</url>
${data.map((url: string) => `
<url>
<loc>${url}</loc>
<lastmod>${generationDate()}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`)
.join('')}
</urlset>`;
return new Response(sitemap, {
headers: { 'Content-Type': 'application/xml' }
}
);
}

View file

@ -0,0 +1,5 @@
<script lang="ts">
import InstallationInstructions from '$lib/InstallationInstructions.svelte';
</script>
<InstallationInstructions />

3
src/store.ts Normal file
View file

@ -0,0 +1,3 @@
import { writable } from 'svelte/store'
export const templates = writable([]);

View file

@ -0,0 +1,88 @@
import yaml from 'js-yaml';
import type { Template, Volume, Service, DockerCompose } from '$src/Types';
export const generateDockerRunCommand = (template: Template) => {
let command = `docker run -d \\ \n`;
if (template.ports) {
template.ports.forEach((port) => {
command += ` -p ${port} \\\n`;
});
}
if (template.env) {
template.env.forEach((env) => {
command += ` -e ${env.name}=\${${env.name}} \\\n`;
});
}
if (template.volumes) {
template.volumes.forEach((volume: Volume) => {
const readOnly = volume.readonly ? ":ro" : "";
command += ` -v ${volume.bind}:${volume.container}${readOnly} \\\n`;
});
}
if (template.restart_policy) {
command += ` --restart=${template.restart_policy} \\\n`;
}
command += ` ${template.image}`;
return command;
};
export const generateDockerRunCommands = (stack: Service[]) => {
const commands = stack.filter((s) => s.image).map((service) => {
let cmd = `docker run --name ${service.name} -d \\\n`;
if (service.command) {
cmd += ` ${service.command} \\\n`;
}
if (service.env) {
service.env.forEach((envVar) => {
cmd += ` -e "${envVar.value}" \\\n`;
});
}
if (service.ports) {
service.ports.forEach((port) => {
cmd += ` -p ${port} \\\n`;
});
}
if (service.volumes) {
service.volumes.forEach((volume) => {
cmd += ` -v ${volume.bind}:${volume.container} \\\n`;
});
}
if (service.restart_policy) {
cmd += ` --restart=${service.restart_policy} \\\n`;
}
cmd += ` ${service.image}`;
return cmd;
});
return commands;
}
export const convertToDockerCompose = (template: Template) => {
const serviceName = template.title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const dockerCompose: DockerCompose = {
version: "3.8",
services: { [serviceName]: { image: template.image } },
};
if (template.ports && template.ports.length > 0) {
dockerCompose.services[serviceName].ports = template.ports.map((port) => port.replace('/', ':'));
}
if (template.env && template.env.length > 0) {
dockerCompose.services[serviceName].environment = template.env.reduce((envVars, envVar) => {
envVars[envVar.name] = envVar.set || "";
return envVars;
}, {});
}
if (template.volumes && template.volumes.length > 0) {
dockerCompose.services[serviceName].volumes = template.volumes.map(
(volume) => `${volume.bind || ""}:${volume.container}`
);
}
return yaml.dump(dockerCompose);
};
export const convertPortainerStackToDockerCompose = (stack: Service[]) => {
const composeStack = stack.map(({ dockerStats, ...s }) => s);
return yaml.dump(composeStack);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
static/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

19
static/manifest.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "Portainer Templates",
"short_name": "Portainer Templates",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#6EDFDE",
"background_color": "#101828",
"display": "standalone"
}

21
svelte.config.js Normal file
View file

@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
alias: {
'$src/*': 'src/*',
},
}
};
export default config;

File diff suppressed because one or more lines are too long

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

7
vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { sentrySvelteKit } from '@sentry/sveltekit';
export default defineConfig({
plugins: [sveltekit(), sentrySvelteKit()],
});