Compare commits
19 Commits
4d7233f32c
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
f6ffc90ec2
|
|||
|
75fe60b4c9
|
|||
|
01a147d2d3
|
|||
|
462ed6491c
|
|||
|
cd6ac6e771
|
|||
|
8c9ad40776
|
|||
|
83512c3584
|
|||
|
b75337f450
|
|||
|
770472c30c
|
|||
|
8e6753ebea
|
|||
|
bc8b9d172b
|
|||
|
a1e5346fdf
|
|||
|
5b8177bd12
|
|||
|
8c18e81358
|
|||
|
0558c719d6
|
|||
|
d31f24c101
|
|||
| d8f3c4fd69 | |||
|
b0e891a59b
|
|||
|
bf466b26ac
|
@@ -1,7 +1,8 @@
|
|||||||
|
PASSPORT_ENABLE_WEATHER=true
|
||||||
OPENWEATHER_API_KEY=1234567890
|
OPENWEATHER_API_KEY=1234567890
|
||||||
OPENWEATHER_LAT=34.052235
|
OPENWEATHER_LAT=34.052235
|
||||||
OPENWEATHER_LON=-118.243683
|
OPENWEATHER_LON=-118.243683
|
||||||
OPENWEATHER_UPDATE_INTERVAL=15
|
OPENWEATHER_UPDATE_INTERVAL=15
|
||||||
PASSPORT_ADMIN_USERNAME=admin
|
PASSPORT_ADMIN_USERNAME=admin
|
||||||
PASSPORT_ADMIN_PASSWORD=P@ssw0rd
|
PASSPORT_ADMIN_PASSWORD=P@ssw0rd
|
||||||
PASSPORT_SEARCH_PROVIDER=https://google.com/search?q=%s
|
PASSPORT_SEARCH_PROVIDER=https://google.com/search
|
||||||
50
.github/workflows/docker-publish.yml
vendored
Normal file
50
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Build and Push Docker Image to GHCR
|
||||||
|
|
||||||
|
env:
|
||||||
|
OCI_TOKEN: ${{ secrets.OCI_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.OCI_REGISTRY || 'ghcr.io' }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ env.OCI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ vars.OCI_REGISTRY || 'ghcr.io' }}/${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
passport
|
passport
|
||||||
.env
|
.env
|
||||||
passport.db*
|
passport.db*
|
||||||
public/uploads/
|
public
|
||||||
zqdgr
|
zqdgr
|
||||||
assets/tailwind.css
|
|
||||||
|
# compiled via go prepare
|
||||||
|
src/assets/tailwind.css
|
||||||
19
.prettierrc
Normal file
19
.prettierrc
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"requirePragma": false,
|
||||||
|
"embeddedLanguageFormatting": "auto",
|
||||||
|
"vueIndentScriptAndStyle": false,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"insertPragma": false
|
||||||
|
}
|
||||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
FROM golang:1.25 AS builder
|
||||||
|
|
||||||
|
# build dependencies
|
||||||
|
RUN apt update && apt install -y upx
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN set -eux; \
|
||||||
|
echo "Building for architecture: ${TARGETARCH}"; \
|
||||||
|
case "${TARGETARCH}" in \
|
||||||
|
"amd64") \
|
||||||
|
arch_suffix='x64' ;; \
|
||||||
|
"arm64") \
|
||||||
|
arch_suffix='arm64' ;; \
|
||||||
|
*) \
|
||||||
|
echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -sLO "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-${arch_suffix}"; \
|
||||||
|
mv "tailwindcss-linux-${arch_suffix}" /usr/local/bin/tailwindcss; \
|
||||||
|
chmod +x /usr/local/bin/tailwindcss;
|
||||||
|
|
||||||
|
|
||||||
|
RUN go install github.com/juls0730/zqdgr@latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH}
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN zqdgr build
|
||||||
|
RUN upx passport
|
||||||
|
|
||||||
|
# ---- Runtime Stage ----
|
||||||
|
FROM gcr.io/distroless/static-debian12 AS runner
|
||||||
|
|
||||||
|
WORKDIR /data
|
||||||
|
COPY --from=builder /app/passport /usr/local/bin/passport
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/passport"]
|
||||||
86
README.md
86
README.md
@@ -6,7 +6,7 @@ Passport is a simple, fast, and lightweight web dashboard/new tab replacement.
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
@@ -15,33 +15,83 @@ Passport is a simple, fast, and lightweight web dashboard/new tab replacement.
|
|||||||
- [sqlite3](https://www.sqlite.org/download.html)
|
- [sqlite3](https://www.sqlite.org/download.html)
|
||||||
- [TailwdinCSS CLI](https://github.com/tailwindlabs/tailwindcss/releases/latest)
|
- [TailwdinCSS CLI](https://github.com/tailwindlabs/tailwindcss/releases/latest)
|
||||||
|
|
||||||
### Usage
|
## Usage
|
||||||
|
|
||||||
1. Clone the repository
|
### Docker
|
||||||
2. Configure the `.env` file, an example is provided in the `.env.example` file, see below for every available environment variable
|
|
||||||
4. Deploy `passport` to your web server
|
|
||||||
5. profit
|
|
||||||
|
|
||||||
#### Configuration
|
Passport is available as a Docker image via this repository. This is the recommended way to run Passport.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name passport -p 3000:3000 -v passport_data:/data -e PASSPORT_ADMIN_USERNAME=admin -e PASSPORT_ADMIN_PASSWORD=password ghcr.io/juls0730/passport:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure to change the admin password to something secure. At `/data` is where all of passport's persistent data will be stored, such as image uploads and the sqlite database.
|
||||||
|
|
||||||
|
### Building from source
|
||||||
|
|
||||||
|
If you want to build from source, you will need to install the dependencies first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# note entirely necessary, but strongly recommended
|
||||||
|
go install github.com/juls0730/zqdgr@latest
|
||||||
|
|
||||||
|
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-x64
|
||||||
|
chmod +x tailwindcss-linux-x64
|
||||||
|
mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
||||||
|
|
||||||
|
# you may also have to install sqlite3...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then you can build the binary.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zqdgr build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then run the binary.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
#### Passport configuration
|
||||||
|
|
||||||
| Environment Variable | Description | Required | Default |
|
| Environment Variable | Description | Required | Default |
|
||||||
| --- | --- | --- | --- |
|
| -------------------------------------- | ------------------------------------------------------------------------------- | -------- | ------- |
|
||||||
| `PASSPORT_DEV_MODE` | Enables dev mode | false | false |
|
| `PASSPORT_DEV_MODE` | Enables dev mode | false | false |
|
||||||
| `PASSPORT_ENABLE_WEATHER` | Enables weather data, requires an OpenWeather API key | false | true |
|
| `PASSPORT_ENABLE_PREFORK` | Enables preforking | false | false |
|
||||||
| `PASSPORT_ENABLE_UPTIME` | Enables uptime data, requires an UptimeRobot API key | false | true |
|
|
||||||
| `PASSPORT_ADMIN_USERNAME` | The username for the admin dashboard | true |
|
| `PASSPORT_ADMIN_USERNAME` | The username for the admin dashboard | true |
|
||||||
| `PASSPORT_ADMIN_PASSWORD` | The password for the admin dashboard | true |
|
| `PASSPORT_ADMIN_PASSWORD` | The password for the admin dashboard | true |
|
||||||
| `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar | true |
|
| `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar, without any query parameters | true |
|
||||||
| `OPENWEATHER_API_KEY` | The OpenWeather API key | if enabled |
|
| `PASSPORT_SEARCH_PROVIDER_QUERY_PARAM` | The query parameter to use for the search provider, e.g. `q` for most providers | false | q |
|
||||||
| `OPENWEATHER_LAT` | The latitude of your location | if enabled |
|
|
||||||
| `OPENWEATHER_LON` | The longitude of your location | if enabled |
|
> [!NOTE]
|
||||||
| `OPENWEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
|
> Currently passport only supports search using a GET request.
|
||||||
| `UPTIMEROBOT_API_KEY` | The UptimeRobot API key | if enabled |
|
|
||||||
| `UPTIMEROBOT_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
|
#### Weather configuration
|
||||||
|
|
||||||
|
The weather integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the OpenWeatherMap integration.
|
||||||
|
|
||||||
|
| Environment Variable | Description | Required | Default |
|
||||||
|
| ------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
|
||||||
|
| `WEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | false | openweathermap |
|
||||||
|
| `WEATHER_API_KEY` | The OpenWeather API key | true | |
|
||||||
|
| `WEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
|
||||||
|
| `WEATHER_LAT` | The latitude of your location | true | |
|
||||||
|
| `WEATHER_LON` | The longitude of your location | true | |
|
||||||
|
| `WEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
|
||||||
|
|
||||||
|
#### Uptime configuration
|
||||||
|
|
||||||
|
The uptime integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the UptimeRobot integration.
|
||||||
|
|
||||||
|
| Environment Variable | Description | Required | Default |
|
||||||
|
| ------------------------ | ------------------------------------------------- | -------- | ------- |
|
||||||
|
| `UPTIME_API_KEY` | The UptimeRobot API key | true | |
|
||||||
|
| `UPTIME_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
|
||||||
|
|
||||||
### Adding links and categories
|
### Adding links and categories
|
||||||
|
|
||||||
The admin dashboard can be accessed at `/admin`, you will be redirected to the login page if you are not logged in, use the credentials you configured in the `.env` file to login. Once logged in you can add links and categories.
|
The admin dashboard can be accessed at `/admin`, you will be redirected to the login page if you are not logged in, use
|
||||||
|
the credentials you configured via the environment variables to login. Once logged in you can add links and categories.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
57
go.mod
57
go.mod
@@ -1,40 +1,53 @@
|
|||||||
module github.com/juls0730/passport
|
module github.com/juls0730/passport
|
||||||
|
|
||||||
go 1.23.2
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
github.com/HugoSmits86/nativewebp v1.2.0
|
||||||
github.com/gofiber/schema v1.2.0 // indirect
|
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta
|
||||||
github.com/mailgun/raymond/v2 v2.0.48 // indirect
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||||
github.com/tinylib/msgp v1.2.5 // indirect
|
golang.org/x/image v0.24.0
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
modernc.org/sqlite v1.39.0
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
|
||||||
golang.org/x/net v0.31.0 // indirect
|
|
||||||
golang.org/x/text v0.21.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/chai2010/webp v1.1.1
|
github.com/gofiber/schema v1.6.0 // indirect
|
||||||
|
github.com/mailgun/raymond/v2 v2.0.48 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||||
|
github.com/tdewolff/parse/v2 v2.8.3 // indirect
|
||||||
|
github.com/tinylib/msgp v1.4.0 // indirect
|
||||||
|
golang.org/x/crypto v0.42.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/net v0.44.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
modernc.org/libc v1.66.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/gofiber/fiber/v2 v2.52.5 // indirect
|
github.com/gofiber/fiber/v2 v2.52.5 // indirect
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.4
|
github.com/gofiber/fiber/v3 v3.0.0-rc.1
|
||||||
github.com/gofiber/template v1.8.3 // indirect
|
github.com/gofiber/template v1.8.3 // indirect
|
||||||
github.com/gofiber/template/handlebars/v2 v2.1.10
|
github.com/gofiber/template/handlebars/v2 v2.1.10
|
||||||
github.com/gofiber/utils v1.1.0 // indirect
|
github.com/gofiber/utils v1.1.0 // indirect
|
||||||
github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect
|
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/klauspost/compress v1.17.11 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
|
||||||
github.com/rivo/uniseg v0.2.0 // indirect
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/tdewolff/minify/v2 v2.24.3 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.58.0 // indirect
|
github.com/valyala/fasthttp v1.66.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
148
go.sum
148
go.sum
@@ -1,96 +1,138 @@
|
|||||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4=
|
||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8=
|
||||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta h1:tNj6Rq9S3UUnF2800h6Ns7wmx+q7MwoZBVD24fPCSlo=
|
||||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta/go.mod h1:AoLhZCGC/9XGqOE+0amArp/dFIZSfZSvbyPI/IbQ7Q0=
|
||||||
github.com/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||||
|
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
|
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
|
||||||
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
|
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg=
|
github.com/gofiber/fiber/v3 v3.0.0-rc.1 h1:034MxesK6bqGkidP+QR+Ysc1ukOacBWOHCarCKC1xfg=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
|
github.com/gofiber/fiber/v3 v3.0.0-rc.1/go.mod h1:hFdT00oT0XVuQH1/z2i5n1pl/msExHDUie1SsLOkCuM=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0=
|
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk=
|
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||||
github.com/gofiber/schema v1.2.0 h1:j+ZRrNnUa/0ZuWrn/6kAtAufEr4jCJ+JuTURAMxNSZg=
|
|
||||||
github.com/gofiber/schema v1.2.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
|
|
||||||
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
|
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
|
||||||
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
|
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
|
||||||
github.com/gofiber/template/handlebars/v2 v2.1.10 h1:Qc+uUMULCqW60LF4VKO1REpiyDAUy3vqW7xq66FPJGM=
|
github.com/gofiber/template/handlebars/v2 v2.1.10 h1:Qc+uUMULCqW60LF4VKO1REpiyDAUy3vqW7xq66FPJGM=
|
||||||
github.com/gofiber/template/handlebars/v2 v2.1.10/go.mod h1:84WH9st5OJi255EGjuMAOqUVQ+Q2jUNhUKYbS5DgAcI=
|
github.com/gofiber/template/handlebars/v2 v2.1.10/go.mod h1:84WH9st5OJi255EGjuMAOqUVQ+Q2jUNhUKYbS5DgAcI=
|
||||||
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
|
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
|
||||||
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
|
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
|
||||||
github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co=
|
github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
|
||||||
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
|
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
|
||||||
github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
|
||||||
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
|
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
|
||||||
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
|
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
|
github.com/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw=
|
||||||
|
github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA=
|
||||||
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
|
github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
|
||||||
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
|
||||||
|
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||||
|
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||||
|
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||||
|
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
|
||||||
|
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU=
|
||||||
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs=
|
||||||
github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE=
|
|
||||||
github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw=
|
|
||||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
|
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||||
|
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
947
main.go
947
main.go
@@ -1,947 +0,0 @@
|
|||||||
//go:generate tailwindcss -i styles/main.css -o assets/tailwind.css --minify
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"database/sql"
|
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"image/jpeg"
|
|
||||||
"image/png"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/chai2010/webp"
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
"github.com/gofiber/fiber/v3/middleware/helmet"
|
|
||||||
"github.com/gofiber/fiber/v3/middleware/static"
|
|
||||||
"github.com/gofiber/template/handlebars/v2"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"github.com/juls0730/passport/middleware"
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
"github.com/nfnt/resize"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed assets/** templates/** schema.sql
|
|
||||||
var embeddedAssets embed.FS
|
|
||||||
|
|
||||||
var devContent = `<script>
|
|
||||||
let host = window.location.hostname;
|
|
||||||
const socket = new WebSocket('ws://' + host + ':2067/ws');
|
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => {
|
|
||||||
if (event.data === 'refresh') {
|
|
||||||
async function testPage() {
|
|
||||||
try {
|
|
||||||
let res = await fetch(window.location.href)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setTimeout(testPage, 300);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
testPage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>`
|
|
||||||
|
|
||||||
var (
|
|
||||||
insertCategoryStmt *sql.Stmt
|
|
||||||
insertLinkStmt *sql.Stmt
|
|
||||||
)
|
|
||||||
|
|
||||||
type App struct {
|
|
||||||
*WeatherCache
|
|
||||||
*CategoryManager
|
|
||||||
*UptimeManager
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewApp(dbPath string) (*App, error) {
|
|
||||||
db, err := sql.Open("sqlite3", dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
schema, err := embeddedAssets.ReadFile("schema.sql")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.Exec(string(schema))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryManager, err := NewCategoryManager(db)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var weatherCache *WeatherCache
|
|
||||||
if os.Getenv("PASSPORT_ENABLE_WEATHER") != "false" {
|
|
||||||
weatherCache = NewWeatherCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
var uptimeManager *UptimeManager
|
|
||||||
if os.Getenv("PASSPORT_ENABLE_UPTIME") != "false" {
|
|
||||||
uptimeManager = NewUptimeManager()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &App{
|
|
||||||
WeatherCache: weatherCache,
|
|
||||||
CategoryManager: categoryManager,
|
|
||||||
UptimeManager: uptimeManager,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type UptimeRobotSite struct {
|
|
||||||
FriendlyName string `json:"friendly_name"`
|
|
||||||
Url string `json:"url"`
|
|
||||||
Status int `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UptimeManager struct {
|
|
||||||
sites []UptimeRobotSite
|
|
||||||
lastUpdate time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
updateChan chan struct{}
|
|
||||||
updateInterval int
|
|
||||||
apiKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUptimeManager() *UptimeManager {
|
|
||||||
if os.Getenv("UPTIMEROBOT_API_KEY") == "" {
|
|
||||||
log.Fatalln("UptimeRobot API Key is required!")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInterval, err := strconv.Atoi(os.Getenv("UPTIMEROBOT_UPDATE_INTERVAL"))
|
|
||||||
if err != nil || updateInterval < 1 {
|
|
||||||
updateInterval = 300
|
|
||||||
}
|
|
||||||
|
|
||||||
uptimeManager := &UptimeManager{
|
|
||||||
updateChan: make(chan struct{}),
|
|
||||||
updateInterval: updateInterval,
|
|
||||||
apiKey: os.Getenv("UPTIMEROBOT_API_KEY"),
|
|
||||||
sites: []UptimeRobotSite{},
|
|
||||||
}
|
|
||||||
|
|
||||||
go uptimeManager.updateWorker()
|
|
||||||
|
|
||||||
uptimeManager.updateChan <- struct{}{}
|
|
||||||
|
|
||||||
return uptimeManager
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UptimeManager) getUptime() []UptimeRobotSite {
|
|
||||||
u.mutex.RLock()
|
|
||||||
defer u.mutex.RUnlock()
|
|
||||||
return u.sites
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UptimeManager) updateWorker() {
|
|
||||||
ticker := time.NewTicker(time.Duration(u.updateInterval) * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-u.updateChan:
|
|
||||||
u.update()
|
|
||||||
case <-ticker.C:
|
|
||||||
u.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type UptimeRobotResponse struct {
|
|
||||||
Monitors []UptimeRobotSite `json:"monitors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UptimeManager) update() {
|
|
||||||
resp, err := http.Post("https://api.uptimerobot.com/v2/getMonitors?api_key="+u.apiKey, "application/json", nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error fetching uptime data: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error reading response: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var monitors UptimeRobotResponse
|
|
||||||
if err := json.Unmarshal(body, &monitors); err != nil {
|
|
||||||
fmt.Printf("Error parsing uptime data: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u.mutex.Lock()
|
|
||||||
u.sites = monitors.Monitors
|
|
||||||
u.lastUpdate = time.Now()
|
|
||||||
u.mutex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenWeatherResponse struct {
|
|
||||||
Weather []struct {
|
|
||||||
Name string `json:"main"`
|
|
||||||
IconId string `json:"icon"`
|
|
||||||
} `json:"weather"`
|
|
||||||
Main struct {
|
|
||||||
Temp float64 `json:"temp"`
|
|
||||||
} `json:"main"`
|
|
||||||
Code int `json:"cod"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WeatherData struct {
|
|
||||||
Temperature float64
|
|
||||||
WeatherText string
|
|
||||||
Icon string
|
|
||||||
}
|
|
||||||
|
|
||||||
type WeatherCache struct {
|
|
||||||
data *WeatherData
|
|
||||||
lastUpdate time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
updateChan chan struct{}
|
|
||||||
tempUnits string
|
|
||||||
updateInterval int
|
|
||||||
apiKey string
|
|
||||||
lat string
|
|
||||||
lon string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWeatherCache() *WeatherCache {
|
|
||||||
if os.Getenv("OPENWEATHER_API_KEY") == "" || os.Getenv("OPENWEATHER_LAT") == "" || os.Getenv("OPENWEATHER_LON") == "" {
|
|
||||||
log.Fatalln("OpenWeather API Key, and your latitude and longitude are required!")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInterval, err := strconv.Atoi(os.Getenv("OPENWEATHER_UPDATE_INTERVAL"))
|
|
||||||
if err != nil || updateInterval < 1 {
|
|
||||||
updateInterval = 15
|
|
||||||
}
|
|
||||||
|
|
||||||
units := os.Getenv("OPENWEATHER_TEMP_UNITS")
|
|
||||||
if units == "" {
|
|
||||||
units = "metric"
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := &WeatherCache{
|
|
||||||
data: &WeatherData{},
|
|
||||||
updateChan: make(chan struct{}),
|
|
||||||
tempUnits: units,
|
|
||||||
updateInterval: updateInterval,
|
|
||||||
apiKey: os.Getenv("OPENWEATHER_API_KEY"),
|
|
||||||
lat: os.Getenv("OPENWEATHER_LAT"),
|
|
||||||
lon: os.Getenv("OPENWEATHER_LON"),
|
|
||||||
}
|
|
||||||
|
|
||||||
go cache.weatherWorker()
|
|
||||||
|
|
||||||
cache.updateChan <- struct{}{}
|
|
||||||
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WeatherCache) GetWeather() WeatherData {
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
return *c.data
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WeatherCache) weatherWorker() {
|
|
||||||
ticker := time.NewTicker(time.Duration(c.updateInterval) * time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.updateChan:
|
|
||||||
c.updateWeather()
|
|
||||||
case <-ticker.C:
|
|
||||||
c.updateWeather()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WeatherCache) updateWeather() {
|
|
||||||
url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%s&lon=%s&appid=%s&units=%s",
|
|
||||||
c.lat, c.lon, c.apiKey, c.tempUnits)
|
|
||||||
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error fetching weather: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error reading response: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var weatherResp OpenWeatherResponse
|
|
||||||
if err := json.Unmarshal(body, &weatherResp); err != nil {
|
|
||||||
fmt.Printf("Error parsing weather data: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the request failed
|
|
||||||
if weatherResp.Code != 200 {
|
|
||||||
// if there is no pre-existing data in the cache
|
|
||||||
if c.data.WeatherText == "" {
|
|
||||||
log.Fatalf("Fetching the weather data failed!\n%s\n", weatherResp.Message)
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mutex.Lock()
|
|
||||||
c.data.Temperature = weatherResp.Main.Temp
|
|
||||||
c.data.WeatherText = weatherResp.Weather[0].Name
|
|
||||||
c.data.Icon = weatherResp.Weather[0].IconId
|
|
||||||
c.lastUpdate = time.Now()
|
|
||||||
c.mutex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fiber.Ctx) (string, error) {
|
|
||||||
srcFile, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer srcFile.Close()
|
|
||||||
|
|
||||||
var img image.Image
|
|
||||||
switch contentType {
|
|
||||||
case "image/jpeg":
|
|
||||||
img, err = jpeg.Decode(srcFile)
|
|
||||||
case "image/png":
|
|
||||||
img, err = png.Decode(srcFile)
|
|
||||||
case "image/webp":
|
|
||||||
img, err = webp.Decode(srcFile)
|
|
||||||
case "image/svg+xml":
|
|
||||||
default:
|
|
||||||
return "", errors.New("unsupported file type")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
assetsDir := "public/uploads"
|
|
||||||
|
|
||||||
iconPath := filepath.Join(assetsDir, fileName)
|
|
||||||
|
|
||||||
if contentType == "image/svg+xml" {
|
|
||||||
if err = c.SaveFile(file, iconPath); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outFile, err := os.Create(iconPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer outFile.Close()
|
|
||||||
|
|
||||||
resizedImg := resize.Resize(64, 0, img, resize.MitchellNetravali)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
options := &webp.Options{Lossless: true, Quality: 80}
|
|
||||||
if err := webp.Encode(&buf, resizedImg, options); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.Copy(outFile, &buf); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
iconPath = "/uploads/" + fileName
|
|
||||||
|
|
||||||
return iconPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Category struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
Links []Link `json:"links"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Link struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
CategoryID int64 `json:"category_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CategoryManager struct {
|
|
||||||
db *sql.DB
|
|
||||||
Categories []Category
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCategoryManager(db *sql.DB) (*CategoryManager, error) {
|
|
||||||
rows, err := db.Query(`
|
|
||||||
SELECT id, name, icon
|
|
||||||
FROM categories
|
|
||||||
ORDER BY id ASC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var categories []Category
|
|
||||||
for rows.Next() {
|
|
||||||
var cat Category
|
|
||||||
if err := rows.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.Query(`
|
|
||||||
SELECT id, category_id, name, description, icon, url
|
|
||||||
FROM links
|
|
||||||
WHERE category_id = ?
|
|
||||||
ORDER BY id ASC
|
|
||||||
`, cat.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var links []Link
|
|
||||||
for rows.Next() {
|
|
||||||
var link Link
|
|
||||||
if err := rows.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description,
|
|
||||||
&link.Icon, &link.URL); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
links = append(links, link)
|
|
||||||
}
|
|
||||||
|
|
||||||
cat.Links = links
|
|
||||||
categories = append(categories, cat)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CategoryManager{
|
|
||||||
db: db,
|
|
||||||
Categories: categories,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Category by ID, returns nil if not found
|
|
||||||
func (manager *CategoryManager) GetCategory(id int64) *Category {
|
|
||||||
var category *Category
|
|
||||||
|
|
||||||
// probably potentially bad
|
|
||||||
for _, cat := range manager.Categories {
|
|
||||||
if cat.ID == id {
|
|
||||||
category = &cat
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return category
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *CategoryManager) CreateCategory(category Category) (*Category, error) {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
insertCategoryStmt, err = manager.db.Prepare(`
|
|
||||||
INSERT INTO categories (name, icon)
|
|
||||||
VALUES (?, ?) RETURNING id`)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer insertCategoryStmt.Close()
|
|
||||||
|
|
||||||
var categoryID int64
|
|
||||||
|
|
||||||
if err := insertCategoryStmt.QueryRow(category.Name, category.Icon).Scan(&categoryID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
category.ID = categoryID
|
|
||||||
manager.Categories = append(manager.Categories, category)
|
|
||||||
|
|
||||||
return &category, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *CategoryManager) CreateLink(db *sql.DB, link Link) (*Link, error) {
|
|
||||||
var err error
|
|
||||||
insertLinkStmt, err = db.Prepare(`
|
|
||||||
INSERT INTO links (category_id, name, description, icon, url)
|
|
||||||
VALUES (?, ?, ?, ?, ?) RETURNING id`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer insertLinkStmt.Close()
|
|
||||||
|
|
||||||
var linkID int64
|
|
||||||
if err := insertLinkStmt.QueryRow(link.CategoryID, link.Name, link.Description, link.Icon, link.URL).Scan(&linkID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
link.ID = linkID
|
|
||||||
|
|
||||||
var cat *Category
|
|
||||||
for i, c := range manager.Categories {
|
|
||||||
if c.ID == link.CategoryID {
|
|
||||||
cat = &manager.Categories[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cat == nil {
|
|
||||||
return nil, fmt.Errorf("category not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
cat.Links = append(cat.Links, link)
|
|
||||||
|
|
||||||
return &link, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *CategoryManager) DeleteLink(id any) error {
|
|
||||||
var icon string
|
|
||||||
if err := manager.db.QueryRow("SELECT icon FROM links WHERE id = ?", id).Scan(&icon); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := manager.db.Exec("DELETE FROM links WHERE id = ?", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if icon != "" {
|
|
||||||
if err := os.Remove(filepath.Join("public/", icon)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var WeatherIcons = map[string]string{
|
|
||||||
"clear-day": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M16 12.005a4 4 0 1 1-4 4a4.005 4.005 0 0 1 4-4m0-2a6 6 0 1 0 6 6a6 6 0 0 0-6-6M5.394 6.813L6.81 5.399l3.505 3.506L8.9 10.319zM2 15.005h5v2H2zm3.394 10.193L8.9 21.692l1.414 1.414l-3.505 3.506zM15 25.005h2v5h-2zm6.687-1.9l1.414-1.414l3.506 3.506l-1.414 1.414zm3.313-8.1h5v2h-5zm-3.313-6.101l3.506-3.506l1.414 1.414l-3.506 3.506zM15 2.005h2v5h-2z"/></svg>`,
|
|
||||||
"clear-night": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M13.503 5.414a15.076 15.076 0 0 0 11.593 18.194a11.1 11.1 0 0 1-7.975 3.39c-.138 0-.278.005-.418 0a11.094 11.094 0 0 1-3.2-21.584M14.98 3a1 1 0 0 0-.175.016a13.096 13.096 0 0 0 1.825 25.981c.164.006.328 0 .49 0a13.07 13.07 0 0 0 10.703-5.555a1.01 1.01 0 0 0-.783-1.565A13.08 13.08 0 0 1 15.89 4.38A1.015 1.015 0 0 0 14.98 3"/></svg>`,
|
|
||||||
"partly-cloudy-day": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M27 15h4v2h-4zm-4-7.413l3-3L27.415 6l-3 3zM15 1h2v4h-2zM4.586 26l3-3l1.415 1.415l-3 3zM4.585 6L6 4.587l3 3l-1.414 1.415z"/><path fill="currentColor" d="M1 15h4v2H1zm25.794 5.342a6.96 6.96 0 0 0-1.868-3.267A9 9 0 0 0 25 16a9 9 0 1 0-14.585 7.033A4.977 4.977 0 0 0 15 30h10a4.995 4.995 0 0 0 1.794-9.658M9 16a6.996 6.996 0 0 1 13.985-.297A6.9 6.9 0 0 0 20 15a7.04 7.04 0 0 0-6.794 5.342a5 5 0 0 0-1.644 1.048A6.97 6.97 0 0 1 9 16m16 12H15a2.995 2.995 0 0 1-.696-5.908l.658-.157l.099-.67a4.992 4.992 0 0 1 9.878 0l.099.67l.658.156A2.995 2.995 0 0 1 25 28"/></svg>`,
|
|
||||||
"partly-cloudy-night": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M30 19a4.97 4.97 0 0 0-3.206-4.658A6.971 6.971 0 0 0 13.758 12.9a13.14 13.14 0 0 1 .131-8.52A1.015 1.015 0 0 0 12.98 3a1 1 0 0 0-.175.016a13.096 13.096 0 0 0 1.825 25.981c.164.006.328 0 .49 0a13.04 13.04 0 0 0 10.29-5.038A4.99 4.99 0 0 0 30 19m-15.297 7.998a11.095 11.095 0 0 1-3.2-21.584a15.2 15.2 0 0 0 .844 9.367A4.988 4.988 0 0 0 15 24h7.677a11.1 11.1 0 0 1-7.556 2.998c-.138 0-.278.004-.418 0M25 22H15a2.995 2.995 0 0 1-.696-5.908l.658-.157l.099-.67a4.992 4.992 0 0 1 9.878 0l.099.67l.658.157A2.995 2.995 0 0 1 25 22"/></svg>`,
|
|
||||||
"mostly-cloudy-day": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M21.743 18.692a6 6 0 0 0 1.057-1.086a5.998 5.998 0 1 0-10.733-4.445A7.56 7.56 0 0 0 6.35 18.25A5.993 5.993 0 0 0 8 30.005h11a5.985 5.985 0 0 0 2.743-11.313M18 10.005a4.004 4.004 0 0 1 4 4a3.96 3.96 0 0 1-.8 2.4a4 4 0 0 1-.94.891a7.54 7.54 0 0 0-6.134-4.24A4 4 0 0 1 18 10.006m1 18H8a3.993 3.993 0 0 1-.673-7.93l.663-.112l.146-.656a5.496 5.496 0 0 1 10.729 0l.146.656l.662.112a3.993 3.993 0 0 1-.673 7.93m7-15.001h4v2h-4zM22.95 7.64l2.828-2.827l1.415 1.414l-2.829 2.828zM17 2.005h2v4h-2zM8.808 6.227l1.414-1.414l2.829 2.828l-1.415 1.414z"/></svg>`,
|
|
||||||
"mostly-cloudy-night": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M29.844 15.035a1.52 1.52 0 0 0-1.23-.866a5.36 5.36 0 0 1-3.41-1.716a6.47 6.47 0 0 1-1.286-6.392a1.6 1.6 0 0 0-.299-1.546a1.45 1.45 0 0 0-1.36-.493l-.019.003a7.93 7.93 0 0 0-6.22 7.431A7.4 7.4 0 0 0 13.5 11a7.55 7.55 0 0 0-7.15 5.244A5.993 5.993 0 0 0 8 28h11a5.977 5.977 0 0 0 5.615-8.088a7.5 7.5 0 0 0 5.132-3.357a1.54 1.54 0 0 0 .097-1.52M19 26H8a3.993 3.993 0 0 1-.673-7.93l.663-.112l.145-.656a5.496 5.496 0 0 1 10.73 0l.145.656l.663.113A3.993 3.993 0 0 1 19 26m4.465-8.001h-.021a5.96 5.96 0 0 0-2.795-1.755a7.5 7.5 0 0 0-2.6-3.677c-.01-.101-.036-.197-.041-.3a6.08 6.08 0 0 1 3.79-6.05a8.46 8.46 0 0 0 1.94 7.596a7.4 7.4 0 0 0 3.902 2.228a5.43 5.43 0 0 1-4.175 1.958"/></svg>`,
|
|
||||||
"light-rain": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M11 30a1 1 0 0 1-.894-1.447l2-4a1 1 0 1 1 1.788.894l-2 4A1 1 0 0 1 11 30"/><path fill="currentColor" d="M24.8 9.136a8.994 8.994 0 0 0-17.6 0A6.497 6.497 0 0 0 8.5 22h10.881l-1.276 2.553a1 1 0 0 0 1.789.894L21.618 22H23.5a6.497 6.497 0 0 0 1.3-12.864M23.5 20h-15a4.498 4.498 0 0 1-.356-8.981l.816-.064l.099-.812a6.994 6.994 0 0 1 13.883 0l.099.812l.815.064A4.498 4.498 0 0 1 23.5 20"/></svg>`,
|
|
||||||
"rain": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M23.5 22h-15A6.5 6.5 0 0 1 7.2 9.14a9 9 0 0 1 17.6 0A6.5 6.5 0 0 1 23.5 22M16 4a7 7 0 0 0-6.94 6.14L9 11h-.86a4.5 4.5 0 0 0 .36 9h15a4.5 4.5 0 0 0 .36-9H23l-.1-.82A7 7 0 0 0 16 4m-2 26a.93.93 0 0 1-.45-.11a1 1 0 0 1-.44-1.34l2-4a1 1 0 1 1 1.78.9l-2 4A1 1 0 0 1 14 30m6 0a.93.93 0 0 1-.45-.11a1 1 0 0 1-.44-1.34l2-4a1 1 0 1 1 1.78.9l-2 4A1 1 0 0 1 20 30M8 30a.93.93 0 0 1-.45-.11a1 1 0 0 1-.44-1.34l2-4a1 1 0 1 1 1.78.9l-2 4A1 1 0 0 1 8 30"/></svg>`,
|
|
||||||
"thunder": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M21 30a1 1 0 0 1-.894-1.447l2-4a1 1 0 1 1 1.788.894l-2 4A1 1 0 0 1 21 30M9 32a1 1 0 0 1-.894-1.447l2-4a1 1 0 1 1 1.788.894l-2 4A1 1 0 0 1 9 32m6.901-1.504l-1.736-.992L17.31 24h-6l4.855-8.496l1.736.992L14.756 22h6.001z"/><path fill="currentColor" d="M24.8 9.136a8.994 8.994 0 0 0-17.6 0a6.493 6.493 0 0 0 .23 12.768l-1.324 2.649a1 1 0 1 0 1.789.894l2-4a1 1 0 0 0-.447-1.341A1 1 0 0 0 9 20.01V20h-.5a4.498 4.498 0 0 1-.356-8.981l.816-.064l.099-.812a6.994 6.994 0 0 1 13.883 0l.099.812l.815.064A4.498 4.498 0 0 1 23.5 20H23v2h.5a6.497 6.497 0 0 0 1.3-12.864"/></svg>`,
|
|
||||||
"snow": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M23.5 22h-15A6.5 6.5 0 0 1 7.2 9.14a9 9 0 0 1 17.6 0A6.5 6.5 0 0 1 23.5 22M16 4a7 7 0 0 0-6.94 6.14L9 11h-.86a4.5 4.5 0 0 0 .36 9h15a4.5 4.5 0 0 0 .36-9H23l-.1-.82A7 7 0 0 0 16 4m-4 21.05L10.95 24L9.5 25.45L8.05 24L7 25.05l1.45 1.45L7 27.95L8.05 29l1.45-1.45L10.95 29L12 27.95l-1.45-1.45zm14 0L24.95 24l-1.45 1.45L22.05 24L21 25.05l1.45 1.45L21 27.95L22.05 29l1.45-1.45L24.95 29L26 27.95l-1.45-1.45zm-7 2L17.95 26l-1.45 1.45L15.05 26L14 27.05l1.45 1.45L14 29.95L15.05 31l1.45-1.45L17.95 31L19 29.95l-1.45-1.45z"/></svg>`,
|
|
||||||
"mist": `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M24.8 11.138a8.994 8.994 0 0 0-17.6 0A6.53 6.53 0 0 0 2 17.5V19a1 1 0 0 0 1 1h12a1 1 0 0 0 0-2H4v-.497a4.52 4.52 0 0 1 4.144-4.482l.816-.064l.099-.812a6.994 6.994 0 0 1 13.883 0l.099.813l.815.063A4.496 4.496 0 0 1 23.5 22H7a1 1 0 0 0 0 2h16.5a6.496 6.496 0 0 0 1.3-12.862"/><rect width="18" height="2" x="2" y="26" fill="currentColor" rx="1"/></svg>`,
|
|
||||||
}
|
|
||||||
|
|
||||||
func getWeatherIcon(iconId string) string {
|
|
||||||
switch iconId {
|
|
||||||
case "01d":
|
|
||||||
return WeatherIcons["clear-day"]
|
|
||||||
case "01n":
|
|
||||||
return WeatherIcons["clear-night"]
|
|
||||||
case "02d", "03d":
|
|
||||||
return WeatherIcons["partly-cloudy-day"]
|
|
||||||
case "02n", "03n":
|
|
||||||
return WeatherIcons["partly-cloudy-night"]
|
|
||||||
case "04d":
|
|
||||||
return WeatherIcons["mostly-cloudy-day"]
|
|
||||||
case "04n":
|
|
||||||
return WeatherIcons["mostly-cloudy-night"]
|
|
||||||
case "09d", "09n":
|
|
||||||
return WeatherIcons["light-rain"]
|
|
||||||
case "10d", "10n":
|
|
||||||
return WeatherIcons["rain"]
|
|
||||||
case "11d", "11n":
|
|
||||||
return WeatherIcons["thunder"]
|
|
||||||
case "13d", "13n":
|
|
||||||
return WeatherIcons["snow"]
|
|
||||||
case "50d", "50n":
|
|
||||||
return WeatherIcons["mist"]
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
if err := godotenv.Load(); err != nil {
|
|
||||||
fmt.Println("No .env file found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if err := os.MkdirAll("public/uploads", 0755); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := NewApp("passport.db?cache=shared&mode=rwc&_journal_mode=WAL")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
templatesDir, err := fs.Sub(embeddedAssets, "templates")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assetsDir, err := fs.Sub(embeddedAssets, "assets")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
css, err := fs.ReadFile(embeddedAssets, "assets/tailwind.css")
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := handlebars.NewFileSystem(http.FS(templatesDir), ".hbs")
|
|
||||||
|
|
||||||
engine.AddFunc("inlineCSS", func() string {
|
|
||||||
return string(css)
|
|
||||||
})
|
|
||||||
|
|
||||||
engine.AddFunc("devContent", func() string {
|
|
||||||
if os.Getenv("PASSPORT_DEV_MODE") == "true" {
|
|
||||||
return devContent
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
|
|
||||||
engine.AddFunc("eq", func(a, b any) bool {
|
|
||||||
return a == b
|
|
||||||
})
|
|
||||||
|
|
||||||
router := fiber.New(fiber.Config{
|
|
||||||
Views: engine,
|
|
||||||
})
|
|
||||||
|
|
||||||
router.Use(helmet.New(helmet.ConfigDefault))
|
|
||||||
|
|
||||||
router.Use("/", static.New("./public", static.Config{
|
|
||||||
Browse: false,
|
|
||||||
MaxAge: 31536000,
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.Use("/assets", static.New("", static.Config{
|
|
||||||
FS: assetsDir,
|
|
||||||
MaxAge: 31536000,
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.Get("/", func(c fiber.Ctx) error {
|
|
||||||
renderData := fiber.Map{
|
|
||||||
"SearchProvider": os.Getenv("PASSPORT_SEARCH_PROVIDER"),
|
|
||||||
"Categories": app.CategoryManager.Categories,
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("PASSPORT_ENABLE_WEATHER") != "false" {
|
|
||||||
weather := app.WeatherCache.GetWeather()
|
|
||||||
|
|
||||||
renderData["WeatherData"] = fiber.Map{
|
|
||||||
"Temp": weather.Temperature,
|
|
||||||
"Desc": weather.WeatherText,
|
|
||||||
"Icon": getWeatherIcon(weather.Icon),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("PASSPORT_ENABLE_UPTIME") != "false" {
|
|
||||||
renderData["UptimeData"] = app.UptimeManager.getUptime()
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Render("views/index", renderData, "layouts/main")
|
|
||||||
})
|
|
||||||
|
|
||||||
router.Use(middleware.AdminMiddleware(app.db))
|
|
||||||
|
|
||||||
router.Get("/admin/login", func(c fiber.Ctx) error {
|
|
||||||
if c.Locals("IsAdmin") != nil {
|
|
||||||
return c.Redirect().To("/admin")
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Render("views/admin/login", fiber.Map{}, "layouts/main")
|
|
||||||
})
|
|
||||||
|
|
||||||
router.Post("/admin/login", func(c fiber.Ctx) error {
|
|
||||||
if c.Locals("IsAdmin") != nil {
|
|
||||||
return c.Redirect().To("/admin")
|
|
||||||
}
|
|
||||||
|
|
||||||
var loginData struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := c.Bind().JSON(&loginData); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if loginData.Username != os.Getenv("PASSPORT_ADMIN_USERNAME") || loginData.Password != os.Getenv("PASSPORT_ADMIN_PASSWORD") {
|
|
||||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"message": "Invalid username or password"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new session
|
|
||||||
sessionID := uuid.NewString()
|
|
||||||
expiresAt := time.Now().Add(time.Hour * 24 * 7)
|
|
||||||
_, err := app.db.Exec(`
|
|
||||||
INSERT INTO sessions (session_id, expires_at)
|
|
||||||
VALUES (?, ?)
|
|
||||||
`, sessionID, expiresAt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set cookie
|
|
||||||
c.Cookie(&fiber.Cookie{
|
|
||||||
Name: "SessionToken",
|
|
||||||
Value: sessionID,
|
|
||||||
Expires: expiresAt,
|
|
||||||
})
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Logged in successfully"})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.Get("/admin", func(c fiber.Ctx) error {
|
|
||||||
if c.Locals("IsAdmin") == nil {
|
|
||||||
return c.Redirect().To("/admin/login")
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Render("views/admin/index", fiber.Map{
|
|
||||||
"Categories": app.CategoryManager.Categories,
|
|
||||||
}, "layouts/main")
|
|
||||||
})
|
|
||||||
|
|
||||||
api := router.Group("/api")
|
|
||||||
{
|
|
||||||
api.Use(func(c fiber.Ctx) error {
|
|
||||||
if c.Locals("IsAdmin") == nil {
|
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"})
|
|
||||||
}
|
|
||||||
return c.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
api.Post("/categories", func(c fiber.Ctx) error {
|
|
||||||
var req struct {
|
|
||||||
Name string `form:"name"`
|
|
||||||
}
|
|
||||||
if err := c.Bind().Form(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
||||||
"message": "Failed to parse request",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name == "" {
|
|
||||||
return fmt.Errorf("name and icon are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := c.FormFile("icon")
|
|
||||||
if err != nil || file == nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
||||||
"message": "Icon is required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.Size > 5*1024*1024 {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
||||||
"message": "File size too large. Maximum size is 5MB",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := file.Header.Get("Content-Type")
|
|
||||||
if contentType != "image/svg+xml" {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
||||||
"message": "Only SVGs are supported for category icons!",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
|
|
||||||
|
|
||||||
iconPath, err := UploadFile(file, filename, contentType, c)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
||||||
"message": "Failed to upload file, please try again!",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
UploadFile(file, iconPath, contentType, c)
|
|
||||||
|
|
||||||
category, err := app.CategoryManager.CreateCategory(Category{
|
|
||||||
Name: req.Name,
|
|
||||||
Icon: iconPath,
|
|
||||||
Links: []Link{},
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
||||||
"message": "Category created successfully",
|
|
||||||
"category": category,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
api.Post("/links", func(c fiber.Ctx) error {
|
|
||||||
var req struct {
|
|
||||||
Name string `form:"name"`
|
|
||||||
Description string `form:"description"`
|
|
||||||
URL string `form:"url"`
|
|
||||||
CategoryID int64 `form:"category_id"`
|
|
||||||
}
|
|
||||||
if err := c.Bind().Form(&req); err != nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
||||||
"message": "Failed to parse request",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name == "" || req.URL == "" {
|
|
||||||
return fmt.Errorf("name and url are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := c.FormFile("icon")
|
|
||||||
if err != nil || file == nil {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
||||||
"message": "Icon is required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.Size > 5*1024*1024 {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
||||||
"message": "File size too large. Maximum size is 5MB",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := file.Header.Get("Content-Type")
|
|
||||||
if !strings.HasPrefix(contentType, "image/") {
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
||||||
"message": "Only image files are allowed",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
|
|
||||||
|
|
||||||
iconPath, err := UploadFile(file, filename, contentType, c)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to upload file", "error", err)
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
||||||
"message": "Failed to upload file, please try again!",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
UploadFile(file, iconPath, contentType, c)
|
|
||||||
|
|
||||||
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
|
|
||||||
CategoryID: req.CategoryID,
|
|
||||||
Name: req.Name,
|
|
||||||
Description: req.Description,
|
|
||||||
Icon: iconPath,
|
|
||||||
URL: req.URL,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to create link", "error", err)
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
||||||
"message": "Failed to create link",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
||||||
"message": "Link created successfully",
|
|
||||||
"link": link,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
api.Delete("/links/:id", func(c fiber.Ctx) error {
|
|
||||||
id := c.Params("id")
|
|
||||||
|
|
||||||
app.CategoryManager.DeleteLink(id)
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
api.Delete("/categories/:id", func(c fiber.Ctx) error {
|
|
||||||
id := c.Params("id")
|
|
||||||
|
|
||||||
rows, err := app.db.Query(`
|
|
||||||
SELECT icon FROM categories WHERE id = ?
|
|
||||||
UNION
|
|
||||||
SELECT icon FROM links WHERE category_id = ?
|
|
||||||
`, id, id)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var icons []string
|
|
||||||
for rows.Next() {
|
|
||||||
var icon string
|
|
||||||
if err := rows.Scan(&icon); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
icons = append(icons, icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := app.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
_, err = tx.Exec("DELETE FROM categories WHERE id = ?", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.Exec("DELETE FROM links WHERE category_id = ?", id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, icon := range icons {
|
|
||||||
if icon == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Remove(filepath.Join("public/", icon)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.Listen(":3000", fiber.ListenConfig{
|
|
||||||
EnablePrefork: os.Getenv("PASSPORT_ENABLE_PREFORK") == "true",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
1333
src/main.go
Normal file
1333
src/main.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -27,12 +29,18 @@ func AdminMiddleware(db *sql.DB) func(c fiber.Ctx) error {
|
|||||||
WHERE session_id = ?
|
WHERE session_id = ?
|
||||||
`, sessionToken).Scan(&session.SessionID, &session.ExpiresAt)
|
`, sessionToken).Scan(&session.SessionID, &session.ExpiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Next()
|
slog.Error("Failed to check session", "error", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": fmt.Sprintf("Failed to check session: %v", err),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionExpiry, err := time.Parse("2006-01-02 15:04:05-07:00", session.ExpiresAt)
|
sessionExpiry, err := time.Parse("2006-01-02 15:04:05-07:00", session.ExpiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Next()
|
slog.Error("Failed to parse session expiry", "error", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"message": fmt.Sprintf("Failed to parse session expiry: %v", err),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionExpiry.Before(time.Now()) {
|
if sessionExpiry.Before(time.Now()) {
|
||||||
1106
src/scripts/admin.js
Normal file
1106
src/scripts/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
111
src/services/uptimeService.go
Normal file
111
src/services/uptimeService.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DepricatedUptimeConfig struct {
|
||||||
|
APIKey string `env:"UPTIMEROBOT_API_KEY"`
|
||||||
|
UpdateInterval int `env:"UPTIMEROBOT_UPDATE_INTERVAL" envDefault:"300"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UptimeConfig struct {
|
||||||
|
APIKey string
|
||||||
|
UpdateInterval int `env:"UPTIME_UPDATE_INTERVAL" envDefault:"300"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UptimeRobotSite struct {
|
||||||
|
FriendlyName string `json:"friendly_name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UptimeManager struct {
|
||||||
|
sites []UptimeRobotSite
|
||||||
|
lastUpdate time.Time
|
||||||
|
mutex sync.RWMutex
|
||||||
|
updateChan chan struct{}
|
||||||
|
updateInterval int
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUptimeManager(config *UptimeConfig) *UptimeManager {
|
||||||
|
if config.APIKey == "" {
|
||||||
|
log.Fatalln("UptimeRobot API Key is required!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInterval := config.UpdateInterval
|
||||||
|
if updateInterval < 1 {
|
||||||
|
updateInterval = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
uptimeManager := &UptimeManager{
|
||||||
|
updateChan: make(chan struct{}),
|
||||||
|
updateInterval: updateInterval,
|
||||||
|
apiKey: config.APIKey,
|
||||||
|
sites: []UptimeRobotSite{},
|
||||||
|
}
|
||||||
|
|
||||||
|
go uptimeManager.updateWorker()
|
||||||
|
|
||||||
|
uptimeManager.updateChan <- struct{}{}
|
||||||
|
|
||||||
|
return uptimeManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UptimeManager) GetUptime() []UptimeRobotSite {
|
||||||
|
u.mutex.RLock()
|
||||||
|
defer u.mutex.RUnlock()
|
||||||
|
return u.sites
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UptimeManager) updateWorker() {
|
||||||
|
ticker := time.NewTicker(time.Duration(u.updateInterval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-u.updateChan:
|
||||||
|
u.update()
|
||||||
|
case <-ticker.C:
|
||||||
|
u.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UptimeRobotResponse struct {
|
||||||
|
Monitors []UptimeRobotSite `json:"monitors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UptimeManager) update() {
|
||||||
|
resp, err := http.Post("https://api.uptimerobot.com/v2/getMonitors?api_key="+u.apiKey, "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error fetching uptime data: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading response: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var monitors UptimeRobotResponse
|
||||||
|
if err := json.Unmarshal(body, &monitors); err != nil {
|
||||||
|
fmt.Printf("Error parsing uptime data: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.mutex.Lock()
|
||||||
|
u.sites = monitors.Monitors
|
||||||
|
u.lastUpdate = time.Now()
|
||||||
|
u.mutex.Unlock()
|
||||||
|
}
|
||||||
158
src/services/weatherService.go
Normal file
158
src/services/weatherService.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WeatherProvider string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpenWeatherMap WeatherProvider = "openweathermap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DepricatedWeatherConfig struct {
|
||||||
|
OpenWeather struct {
|
||||||
|
Provider WeatherProvider `env:"OPENWEATHER_PROVIDER" envDefault:"openweathermap"`
|
||||||
|
APIKey string `env:"OPENWEATHER_API_KEY"`
|
||||||
|
Units string `env:"OPENWEATHER_TEMP_UNITS" envDefault:"metric"`
|
||||||
|
Lat float64 `env:"OPENWEATHER_LAT"`
|
||||||
|
Lon float64 `env:"OPENWEATHER_LON"`
|
||||||
|
}
|
||||||
|
UpdateInterval int `env:"OPENWEATHER_UPDATE_INTERVAL" envDefault:"15"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeatherConfig struct {
|
||||||
|
Provider WeatherProvider `env:"WEATHER_PROVIDER" envDefault:"openweathermap"`
|
||||||
|
APIKey string `env:"WEATHER_API_KEY"`
|
||||||
|
Units string `env:"WEATHER_TEMP_UNITS" envDefault:"metric"`
|
||||||
|
Lat float64 `env:"WEATHER_LAT"`
|
||||||
|
Lon float64 `env:"WEATHER_LON"`
|
||||||
|
UpdateInterval int `env:"WEATHER_UPDATE_INTERVAL" envDefault:"15"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenWeatherResponse struct {
|
||||||
|
Weather []struct {
|
||||||
|
Name string `json:"main"`
|
||||||
|
IconId string `json:"icon"`
|
||||||
|
} `json:"weather"`
|
||||||
|
Main struct {
|
||||||
|
Temp float64 `json:"temp"`
|
||||||
|
} `json:"main"`
|
||||||
|
Code int `json:"cod"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeatherData struct {
|
||||||
|
Temperature float64
|
||||||
|
WeatherText string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeatherManager struct {
|
||||||
|
data *WeatherData
|
||||||
|
lastUpdate time.Time
|
||||||
|
mutex sync.RWMutex
|
||||||
|
updateChan chan struct{}
|
||||||
|
config *WeatherConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWeatherManager(config *WeatherConfig) *WeatherManager {
|
||||||
|
if config.Provider != OpenWeatherMap {
|
||||||
|
log.Fatalln("Only OpenWeatherMap is supported!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.APIKey == "" {
|
||||||
|
log.Fatalln("An API Key required for OpenWeather!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInterval := config.UpdateInterval
|
||||||
|
if updateInterval < 1 {
|
||||||
|
updateInterval = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
units := config.Units
|
||||||
|
if units == "" {
|
||||||
|
units = "metric"
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &WeatherManager{
|
||||||
|
data: &WeatherData{},
|
||||||
|
updateChan: make(chan struct{}),
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
go cache.weatherWorker()
|
||||||
|
|
||||||
|
cache.updateChan <- struct{}{}
|
||||||
|
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeatherManager) GetWeather() WeatherData {
|
||||||
|
c.mutex.RLock()
|
||||||
|
defer c.mutex.RUnlock()
|
||||||
|
return *c.data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeatherManager) weatherWorker() {
|
||||||
|
ticker := time.NewTicker(time.Duration(c.config.UpdateInterval) * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.updateChan:
|
||||||
|
c.updateWeather()
|
||||||
|
case <-ticker.C:
|
||||||
|
c.updateWeather()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeatherManager) updateWeather() {
|
||||||
|
url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%s&units=%s",
|
||||||
|
c.config.Lat, c.config.Lon, c.config.APIKey, c.config.Units)
|
||||||
|
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error fetching weather: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading response: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var weatherResp OpenWeatherResponse
|
||||||
|
if err := json.Unmarshal(body, &weatherResp); err != nil {
|
||||||
|
fmt.Printf("Error parsing weather data: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the request failed
|
||||||
|
if weatherResp.Code != 200 {
|
||||||
|
// if there is no pre-existing data in the cache
|
||||||
|
if c.data.WeatherText == "" {
|
||||||
|
log.Fatalf("Fetching the weather data failed!\n%s\n", weatherResp.Message)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mutex.Lock()
|
||||||
|
c.data.Temperature = weatherResp.Main.Temp
|
||||||
|
c.data.WeatherText = weatherResp.Weather[0].Name
|
||||||
|
c.data.Icon = weatherResp.Weather[0].IconId
|
||||||
|
c.lastUpdate = time.Now()
|
||||||
|
c.mutex.Unlock()
|
||||||
|
}
|
||||||
70
src/styles/adminUi.css
Normal file
70
src/styles/adminUi.css
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
.modal-bg {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-bg.is-visible {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.modal-bg {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
transition: opacity 0.3s ease, visibility 0s 0.3s;
|
||||||
|
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-bg.is-visible {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
|
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background-color: var(--color-highlight-sm);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-highlight) 70%, #0000);
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s cubic-bezier(0.45, 0, 0.55, 1);
|
||||||
|
contain: layout style paint;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(125%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
filter: brightness(95%);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/styles/main.scss
Normal file
195
src/styles/main.scss
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-accent: oklch(57.93% 0.258 294.12);
|
||||||
|
--color-success: oklch(70.19% 0.158 160.44);
|
||||||
|
--color-error: oklch(53% 0.251 28.48);
|
||||||
|
|
||||||
|
--color-base: oklch(11% 0.007 285);
|
||||||
|
--color-surface: oklch(19% 0.007 285.66);
|
||||||
|
--color-overlay: oklch(26% 0.008 285.66);
|
||||||
|
|
||||||
|
--color-muted: oklch(63% 0.015 286);
|
||||||
|
--color-subtle: oklch(72% 0.015 286);
|
||||||
|
--color-text: oklch(87% 0.015 286);
|
||||||
|
|
||||||
|
--color-highlight-sm: oklch(30.67% 0.007 286);
|
||||||
|
--color-highlight: oklch(39.26% 0.01 286);
|
||||||
|
--color-highlight-lg: oklch(47.72% 0.011 286);
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Instrument Sans";
|
||||||
|
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2")
|
||||||
|
format("woff2");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui,
|
||||||
|
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
|
"Noto Color Emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
line-height: normal;
|
||||||
|
color-scheme: dark;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(42px, 10vw, 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(30px, 6vw, 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not(.search) {
|
||||||
|
@apply px-4 py-2 rounded-md w-full bg-surface border border-highlight/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden;
|
||||||
|
|
||||||
|
&[type="file"] {
|
||||||
|
@apply p-0 cursor-pointer;
|
||||||
|
|
||||||
|
&::file-selector-button {
|
||||||
|
@apply px-2 py-2 mr-1 bg-highlight text-subtle cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card {
|
||||||
|
background: var(--color-overlay);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.625rem;
|
||||||
|
align-items: center;
|
||||||
|
transition-property: box-shadow, transform, translate;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
contain: layout style paint;
|
||||||
|
|
||||||
|
&:not(.admin) {
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||||
|
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.link-card {
|
||||||
|
transition: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Div that holds the image */
|
||||||
|
.link-card div[data-img-container] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card div[data-img-container] img {
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Div that holds the text */
|
||||||
|
.link-card div[data-text-container] {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card div[data-text-container] p {
|
||||||
|
color: var(--color-subtle);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border: 1px solid #0000;
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-link-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.625rem;
|
||||||
|
border: 0.125rem dashed var(--color-subtle);
|
||||||
|
border-radius: 1rem;
|
||||||
|
transition: box-shadow, transofrm 150ms cubic-bezier(0.45, 0, 0.55, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoy-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header div[data-img-container] {
|
||||||
|
@apply shrink-0 relative mr-2 h-full flex items-center justify-center size-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoy-header div[data-img-container] img {
|
||||||
|
user-select: none;
|
||||||
|
object-fit: cover;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header h2 {
|
||||||
|
text-transform: capitalize;
|
||||||
|
word-break: break-all;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem;
|
||||||
|
contain: layout style paint;
|
||||||
|
}
|
||||||
20
src/templates/layouts/admin.hbs
Normal file
20
src/templates/layouts/admin.hbs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Passport</title>
|
||||||
|
<link rel="favicon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
|
||||||
|
href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" />
|
||||||
|
{{{embedFile "assets/tailwind.css"}}}
|
||||||
|
{{{embedFile "styles/adminUi.css"}}}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-surface text-text">
|
||||||
|
{{embed}}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
{{{devContent}}}
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -3,14 +3,17 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Passport</title>
|
<title>Passport</title>
|
||||||
<link rel="favicon" href="/favicon.ico">
|
<link rel="favicon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous" href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2">
|
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
|
||||||
<style>{{{inlineCSS}}}</style>
|
href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" />
|
||||||
|
{{{embedFile "assets/tailwind.css"}}}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-[#151316] text-white">
|
<body class="bg-surface text-text">
|
||||||
{{embed}}
|
{{embed}}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
{{{devContent}}}
|
{{{devContent}}}
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
17
src/templates/partials/modals/category-form.hbs
Normal file
17
src/templates/partials/modals/category-form.hbs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<div id="category-contents" class="hidden">
|
||||||
|
<h3>Create A category</h3>
|
||||||
|
<form id="category-form" action="/api/categories" method="post"
|
||||||
|
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
||||||
|
<div>
|
||||||
|
<label for="categoryName">Name</label>
|
||||||
|
<input required type="text" name="name" id="categoryName" maxlength="50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="linkIcon">Icon</label>
|
||||||
|
<input type="file" name="icon" id="linkIcon" accept=".svg" required />
|
||||||
|
</div>
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Create
|
||||||
|
category</button>
|
||||||
|
</form>
|
||||||
|
<span id="category-message"></span>
|
||||||
|
</div>
|
||||||
13
src/templates/partials/modals/delete-category.hbs
Normal file
13
src/templates/partials/modals/delete-category.hbs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<div id="category-delete-contents" class="hidden text-center">
|
||||||
|
<h3>Are you sure you want to delete this category?</h3>
|
||||||
|
<p class="mb-3">You are about to delete the category <strong id="category-name"></strong>. This action cannot be
|
||||||
|
undone.
|
||||||
|
All links associated with this category will also be deleted. Are you sure you want to continue?</p>
|
||||||
|
<div class="flex justify-end flex-col gap-y-2">
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-error text-white border-0"
|
||||||
|
onclick="confirmDeleteCategory()">Delete
|
||||||
|
category</button>
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-overlay border border-highlight text-white"
|
||||||
|
onclick="closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
12
src/templates/partials/modals/delete-link.hbs
Normal file
12
src/templates/partials/modals/delete-link.hbs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<div id="link-delete-contents" class="hidden text-center">
|
||||||
|
<h3>Are you sure you want to delete this link?</h3>
|
||||||
|
<p class="mb-3">You are about to delete the link <strong id="link-name"></strong>. This action cannot be undone. Are
|
||||||
|
you sure you
|
||||||
|
want to continue?</p>
|
||||||
|
<div class="flex justify-end flex-col gap-y-2">
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-error text-white border-0" onclick="confirmDeleteLink()">Delete
|
||||||
|
link</button>
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-overlay border border-highlight text-white"
|
||||||
|
onclick="closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
25
src/templates/partials/modals/link-form.hbs
Normal file
25
src/templates/partials/modals/link-form.hbs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<div id="link-contents" class="hidden">
|
||||||
|
<h3>Add A link</h3>
|
||||||
|
<form id="link-form" action="/api/links" method="post"
|
||||||
|
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
||||||
|
<div>
|
||||||
|
<label for="linkName">Name</label>
|
||||||
|
<input required type="text" name="name" id="linkName" maxlength="50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="linkDesc">Description (optional)</label>
|
||||||
|
<input type="text" name="description" id="linkDesc" maxlength="150" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="linkURL">URL</label>
|
||||||
|
<input required type="url" name="url" id="linkURL" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="linkIcon">Icon</label>
|
||||||
|
<input required type="file" name="icon" id="linkIcon" accept="image/*" />
|
||||||
|
</div>
|
||||||
|
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Add
|
||||||
|
link</button>
|
||||||
|
</form>
|
||||||
|
<span id="link-message"></span>
|
||||||
|
</div>
|
||||||
208
src/templates/views/admin/index.hbs
Normal file
208
src/templates/views/admin/index.hbs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<div id="blur-target"
|
||||||
|
class="transition-[filter] motion-reduce:transition-none ease-[cubic-bezier(0.45,0,0.55,1)] duration-300">
|
||||||
|
<header class="flex w-full p-3">
|
||||||
|
<a href="/"
|
||||||
|
class="flex items-center flex-row gap-2 text-white border-b hover:border-transparent justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||||
|
<path d="m9 14l-4-4l4-4" />
|
||||||
|
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Return to home
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="flex justify-center w-full">
|
||||||
|
<div class="w-full sm:w-4/5 p-2.5">
|
||||||
|
{{#each Categories}}
|
||||||
|
<div class="flex items-center category-header" id="{{this.ID}}_category">
|
||||||
|
<div class="category-img" data-img-container>
|
||||||
|
<img width="32" height="32" draggable="false" alt="{{this.Name}}" src="{{this.Icon}}" />
|
||||||
|
</div>
|
||||||
|
<h2 data-placeholder="Enter title...">{{~ this.Name ~}}</h2>
|
||||||
|
<div class="pl-2" data-edit-actions>
|
||||||
|
<div class="flex flex-row gap-2" data-primary-actions>
|
||||||
|
<button aria-label="Edit category" onclick="editCategory(this)" class="action-button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
|
||||||
|
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button aria-label="Delete category" onclick="deleteCategory(this)"
|
||||||
|
class="text-error action-button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="link-grid">
|
||||||
|
{{#each this.Links}}
|
||||||
|
<div id="{{this.ID}}_link" class="link-card relative admin">
|
||||||
|
<div class="relative" data-img-container>
|
||||||
|
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||||
|
</div>
|
||||||
|
<div data-text-container>
|
||||||
|
<h3 class="border border-transparent" data-placeholder="Enter title...">
|
||||||
|
{{~ this.Name ~}}
|
||||||
|
</h3>
|
||||||
|
<!-- add 2 to the height to account for the border -->
|
||||||
|
<p data-placeholder="Enter description...">
|
||||||
|
{{~ this.Description ~}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="absolute right-1 top-1" data-edit-actions>
|
||||||
|
<div class="flex flex-row gap-2" data-primary-actions>
|
||||||
|
<button aria-label="Edit link" onclick="editLink(this)" class="action-button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
|
||||||
|
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button aria-label="Delete link" onclick="deleteLink(this)"
|
||||||
|
class="text-error action-button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
<div onclick="openModal('link', {{this.ID}})" class="new-link-card">
|
||||||
|
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2" d="M12 5v14m-7-7h14" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3>Add a link</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
<div class="flex items-center" id="add-category-button">
|
||||||
|
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2" d="M12 5v14m-7-7h14" />
|
||||||
|
</svg>
|
||||||
|
<h2 onclick="openModal('category')" class="text-subtle underline decoration-dashed cursor-pointer">
|
||||||
|
Add a new category
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="file" id="icon-upload" accept="image/*" style="display: none;" />
|
||||||
|
<div id="modal-container" role="dialog" aria-modal="true"
|
||||||
|
class="modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center hidden">
|
||||||
|
<div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm">
|
||||||
|
{{> 'partials/modals/category-form' }}
|
||||||
|
{{> 'partials/modals/link-form' }}
|
||||||
|
{{> 'partials/modals/delete-link' }}
|
||||||
|
{{> 'partials/modals/delete-category' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- store a blank link card so that if we add a new link we can clone it to make the editing experience easier -->
|
||||||
|
<div id="template-link-card" class="hidden">
|
||||||
|
<div class="relative" data-img-container>
|
||||||
|
<img width="64" height="64" draggable="false" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow flex flex-col gap-y-px overflow-hidden" data-text-container>
|
||||||
|
<h3 class="border border-transparent"></h3>
|
||||||
|
<!-- add 2 to the height to account for the border -->
|
||||||
|
<p class="min-h-[22px] border border-transparent"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="template-category" class="hidden">
|
||||||
|
<div class="flex items-center category-header">
|
||||||
|
<div class="category-img" data-img-container>
|
||||||
|
<img width="32" height="32" draggable="false" />
|
||||||
|
</div>
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
<div class="link-grid">
|
||||||
|
<div class="new-link-card">
|
||||||
|
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 5v14m-7-7h14" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3>Add a link</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="template-edit-actions" class="hidden" data-edit-actions>
|
||||||
|
<div class="flex flex-row gap-2" data-primary-actions>
|
||||||
|
<button class="action-button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||||
|
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
|
||||||
|
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="text-error action-button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="teleport-storage" class="absolute -top-full -left-full hidden">
|
||||||
|
<!-- These are the elements that appear when the user enters edit mode, they allow for the cancelation/confirmation of the edit -->
|
||||||
|
<div class="flex flex-row gap-2" data-confirm-actions id="confirm-actions">
|
||||||
|
<button class="action-button text-success" onclick="confirmEdit()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="m5 12l5 5L20 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-button text-error" onclick="cancelEdit()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0m15.364-6.364L5.636 18.364" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- This is the element that appears on top of the icon when the user is editing it that allows for changing the icon -->
|
||||||
|
<button id="select-icon-button" onclick="selectIcon()"
|
||||||
|
class="flex absolute inset-0 bg-highlight/80 rounded-md text-base items-center justify-center"
|
||||||
|
draggable="false">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{{embedFile "scripts/admin.js"}}}
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
<main class="flex justify-center items-center h-screen relative bg-[#0E0A0E]">
|
<main class="flex justify-center items-center h-screen relative bg-base">
|
||||||
<div class="flex bg-[#151316] rounded-xl overflow-hidden">
|
<div class="flex bg-surface rounded-xl overflow-hidden">
|
||||||
<img src="/assets/leaves.webp" class="h-96 w-64 object-cover" />
|
<img src="/assets/leaves.webp" class="h-96 w-64 object-cover" />
|
||||||
<div class="flex flex-col p-4 text-center">
|
<div class="flex flex-col p-4 text-center">
|
||||||
<h2 class="text-2xl">
|
<h2 class="text-2xl">
|
||||||
Login
|
Login
|
||||||
</h2>
|
</h2>
|
||||||
<form action="/admin/login" method="post" class="flex flex-col gap-y-3 my-2">
|
<form action="/admin/login" method="post" class="flex flex-col gap-y-3 my-2">
|
||||||
<input
|
<input type="text" name="username" placeholder="Username" />
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
<input type="password" name="password" placeholder="Password" />
|
||||||
type="text" name="username" placeholder="Username" />
|
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Login</button>
|
||||||
<input
|
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
|
||||||
type="password" name="password" placeholder="Password" />
|
|
||||||
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0"
|
|
||||||
type="submit">Login</button>
|
|
||||||
</form>
|
</form>
|
||||||
<span id="message"></span>
|
<span id="message"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<main class="grid grid-rows-3 grid-cols-[1fr] justify-center items-center h-screen bg-[#0E0A0E]">
|
<main class="grid grid-rows-3 grid-cols-[1fr] justify-center items-center h-screen bg-base">
|
||||||
<div class="flex h-full p-2.5 justify-between">
|
<div class="flex h-full p-2.5 justify-between">
|
||||||
<div>
|
<div>
|
||||||
{{#if WeatherData}}
|
{{#if WeatherData}}
|
||||||
<div class="text-[#BABABA] flex items-center">
|
<div class="text-subtle flex items-center">
|
||||||
<span class="mr-2 flex items-center">
|
<span class="mr-2 flex items-center">
|
||||||
{{{WeatherData.Icon}}}
|
{{{WeatherData.Icon}}}
|
||||||
</span>
|
</span>
|
||||||
@@ -15,20 +15,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{#if UptimeData}}
|
{{#if UptimeData}}
|
||||||
<div class="text-[#BABABA] flex items-end flex-col">
|
<div class="text-subtle flex items-end flex-col">
|
||||||
{{#each UptimeData}}
|
{{#each UptimeData}}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="mr-2 flex items-center">
|
<span class="mr-2 flex items-center">
|
||||||
{{{this.FriendlyName}}}
|
{{{this.FriendlyName}}}
|
||||||
</span>
|
</span>
|
||||||
<div class="relative my-auto">
|
<div class="relative my-auto size-2">
|
||||||
{{#if (eq this.Status 2)}}
|
<div class="relative my-auto size-2 flex-shrink-0 flex-grow-0">
|
||||||
<span class="absolute w-2 h-2 rounded-full bg-emerald-400 animate-ping block"></span>
|
<svg class="absolute w-full h-full animate-ping" viewBox="0 0 10 10">
|
||||||
<span class="relative w-2 h-2 rounded-full bg-emerald-500 block"></span>
|
<circle cx="5" cy="5" r="5"
|
||||||
{{else}}
|
class="fill-current {{#if (eq this.Status 2)}} text-success {{else}} text-error {{/if}}">
|
||||||
<span class="absolute w-2 h-2 rounded-full bg-rose-400 animate-ping block"></span>
|
</circle>
|
||||||
<span class="relative w-2 h-2 rounded-full bg-rose-500 block"></span>
|
</svg>
|
||||||
{{/if}}
|
<svg class="relative w-full h-full" viewBox="0 0 10 10">
|
||||||
|
<circle cx="5" cy="5" r="5"
|
||||||
|
class="fill-current {{#if (eq this.Status 2)}} text-success {{else}} text-error {{/if}}">
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
@@ -38,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row-start-2 flex flex-col items-center w-full px-6">
|
<div class="row-start-2 flex flex-col items-center w-full px-6">
|
||||||
<div class="flex items-center pb-2.5">
|
<div class="flex items-center pb-2.5">
|
||||||
<svg class="mr-3 aspect-square w-[clamp(48px,10vw,60px)]" viewBox="0 0 100 100" fill="none"
|
<svg class="mr-3 aspect-square w-[clamp(42px,10vw,60px)]" viewBox="0 0 100 100" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="12.1483" y="24.7693" width="70" height="47" rx="12" transform="rotate(14.63 12.1483 24.7693)"
|
<rect x="12.1483" y="24.7693" width="70" height="47" rx="12" transform="rotate(14.63 12.1483 24.7693)"
|
||||||
fill="url(#paint0_linear_20_10)" />
|
fill="url(#paint0_linear_20_10)" />
|
||||||
@@ -60,44 +65,36 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h1>Passport</h1>
|
<h1>Passport</h1>
|
||||||
</div>
|
</div>
|
||||||
<input id="search-input"
|
<form class="w-full max-w-3xl" action="{{ SearchProviderURL }}" method="GET">
|
||||||
class="w-full max-w-3xl bg-[#1C1C21] border border-[#56565b]/30 rounded-full px-3 py-1 text-white h-7 focus-visible:outline-none placeholder:italic placeholder:text-[#434343]"
|
<input name="{{ SearchParam }}" aria-label="Search bar"
|
||||||
|
class="w-full bg-surface border border-highlight-sm/70 rounded-full px-3 py-1 text-white h-7 focus-visible:outline-none placeholder:italic placeholder:text-highlight search"
|
||||||
placeholder="Search..." />
|
placeholder="Search..." />
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<section class="flex justify-center w-full">
|
<section class="flex justify-center w-full bg-surface">
|
||||||
<div class="w-full sm:w-4/5 p-2.5">
|
<div class="w-full sm:w-4/5 p-2.5">
|
||||||
{{#each Categories}}
|
{{#each Categories}}
|
||||||
<div class="flex items-center w-fit">
|
<div class="flex items-center mt-2 first:mt-0">
|
||||||
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
<img class="object-contain mr-2 select-none size-8" width="32" height="32" draggable="false"
|
||||||
src="{{this.Icon}}" />
|
alt="{{this.Name}}" src="{{this.Icon}}" />
|
||||||
<h2 class="capitalize w-fit">{{this.Name}}</h2>
|
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
<div class="link-grid">
|
||||||
{{#each this.Links}}
|
{{#each this.Links}}
|
||||||
<a href="{{this.URL}}"
|
<a href="{{this.URL}}" class="link-card" draggable="false" target="_blank" rel="noopener noreferrer">
|
||||||
class="underline-none text-unset rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-1"
|
<div data-img-container>
|
||||||
draggable="false" target="_blank" rel="noopener noreferrer">
|
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||||
<img class="object-contain mr-2 select-none rounded-md" width="64" height="64" draggable="false"
|
</div>
|
||||||
src="{{this.Icon}}" alt="{{this.Name}}" />
|
<div data-text-container>
|
||||||
<div>
|
|
||||||
<h3>{{this.Name}}</h3>
|
<h3>{{this.Name}}</h3>
|
||||||
<p class="text-[#D7D7D7]">{{this.Description}}</p>
|
<p class="min-h-5">{{this.Description}}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-subtle">No links here, add one!</p>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
|
||||||
let search_input = document.getElementById("search-input");
|
|
||||||
let search_provider = "{{ SearchProvider }}";
|
|
||||||
// on enter key press
|
|
||||||
search_input.addEventListener("keyup", function (event) {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
window.location.href = search_provider.replace("%s", search_input.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Instrument Sans";
|
|
||||||
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2");
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--default-font-family: "Instrument Sans", ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
line-height: normal;
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
@apply font-semibold;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(48px, 10vw, 64px);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: clamp(30px, 6vw, 36px);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
<section class="flex justify-center w-full">
|
|
||||||
<div class="w-full sm:w-4/5 p-2.5">
|
|
||||||
{{#each Categories}}
|
|
||||||
<div class="flex items-center">
|
|
||||||
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
|
||||||
src="{{this.Icon}}" />
|
|
||||||
<h2 class="capitalize">{{this.Name}}</h2>
|
|
||||||
<button onclick="deleteCategory({{this.ID}})"
|
|
||||||
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] cursor-pointer"><svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
|
||||||
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
|
||||||
</svg></button>
|
|
||||||
</div>
|
|
||||||
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
|
||||||
{{#each this.Links}}
|
|
||||||
<div
|
|
||||||
class="rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] relative">
|
|
||||||
<img class="object-contain mr-2 select-none rounded-md" width="64" height="64" draggable="false"
|
|
||||||
src="{{this.Icon}}" alt="{{this.Name}}" />
|
|
||||||
<div>
|
|
||||||
<h3>{{this.Name}}</h3>
|
|
||||||
<p class="text-[#D7D7D7]">{{this.Description}}</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="deleteLink({{this.ID}})"
|
|
||||||
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] cursor-pointer absolute right-1 top-1"><svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
|
||||||
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
|
||||||
</svg></button>
|
|
||||||
</div>
|
|
||||||
{{/each}}
|
|
||||||
<div onclick="addLink({{this.ID}})"
|
|
||||||
class="rounded-2xl border border-dashed border-[#656565] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] pointer-cursor select-none cursor-pointer">
|
|
||||||
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
|
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
stroke-width="2" d="M12 5v14m-7-7h14" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h3>Add a link</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{/each}}
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
|
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M12 5v14m-7-7h14" />
|
|
||||||
</svg>
|
|
||||||
<h2 onclick="addCategory()" class="text-[#656565] underline decoration-dashed cursor-pointer">
|
|
||||||
Add a new category
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<div id="linkModal" class="hidden absolute top-0 left-0 bottom-0 right-0 bg-[#00000070] justify-center items-center">
|
|
||||||
<div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4">
|
|
||||||
<h3>Add A link</h3>
|
|
||||||
<form id="link-form" action="/api/links" method="post" class="flex flex-col gap-y-3 my-2">
|
|
||||||
<div>
|
|
||||||
<label for="linkName">Name</label>
|
|
||||||
<input
|
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
|
||||||
type="text" name="name" placeholder="Name" id="linkName" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="linkDesc">Description</label>
|
|
||||||
<input
|
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
|
||||||
type="text" name="description" placeholder="Description" id="linkDesc" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="linkURL">URL</label>
|
|
||||||
<input
|
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
|
||||||
type="text" name="url" placeholder="URL" id="linkURL" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="linkIcon">Icon</label>
|
|
||||||
<input class="w-full text-white" type="file" name="icon" id="linkIcon" accept="image/*" />
|
|
||||||
</div>
|
|
||||||
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Add</button>
|
|
||||||
</form>
|
|
||||||
<span id="link-message"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="categoryModal"
|
|
||||||
class="hidden absolute top-0 left-0 bottom-0 right-0 bg-[#00000070] justify-center items-center">
|
|
||||||
<div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4">
|
|
||||||
<h3>Create A category</h3>
|
|
||||||
<form id="category-form" action="/api/categories" method="post" class="flex flex-col gap-y-3 my-2">
|
|
||||||
<div>
|
|
||||||
<label for="categoryName">Name</label>
|
|
||||||
<input
|
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
|
||||||
type="text" name="name" placeholder="Name" id="categoryName" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="linkIcon">Icon</label>
|
|
||||||
<input class="w-full text-white" type="file" name="icon" id="linkIcon" accept=".svg" />
|
|
||||||
</div>
|
|
||||||
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Create</button>
|
|
||||||
</form>
|
|
||||||
<span id="category-message"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// idfk what this variable capitalization is, it's a mess
|
|
||||||
let linkModal = document.getElementById("linkModal");
|
|
||||||
let categoryModal = document.getElementById("categoryModal");
|
|
||||||
let targetCategoryID = null;
|
|
||||||
|
|
||||||
function addCategory() {
|
|
||||||
categoryModal.classList.remove("hidden");
|
|
||||||
categoryModal.classList.add("flex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLink(categoryID) {
|
|
||||||
targetCategoryID = categoryID;
|
|
||||||
linkModal.classList.remove("hidden");
|
|
||||||
linkModal.classList.add("flex");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteLink(linkID) {
|
|
||||||
let res = await fetch(`/api/links/${linkID}`, {
|
|
||||||
method: "DELETE"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCategory(categoryID) {
|
|
||||||
let res = await fetch(`/api/categories/${categoryID}`, {
|
|
||||||
method: "DELETE"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("link-form").addEventListener("submit", async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
let data = new FormData(event.target);
|
|
||||||
|
|
||||||
data.append("category_id", targetCategoryID);
|
|
||||||
|
|
||||||
let res = await fetch(`/api/links`, {
|
|
||||||
method: "POST",
|
|
||||||
body: data
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 201) {
|
|
||||||
linkModal.classList.add("hidden");
|
|
||||||
linkModal.classList.remove("flex");
|
|
||||||
document.getElementById("link-form").reset();
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
let json = await res.json();
|
|
||||||
document.getElementById("category-message").innerText = json.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("category-form").addEventListener("submit", async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
let data = new FormData(event.target);
|
|
||||||
|
|
||||||
let res = await fetch(`/api/categories`, {
|
|
||||||
method: "POST",
|
|
||||||
body: data
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 201) {
|
|
||||||
categoryModal.classList.add("hidden");
|
|
||||||
categoryModal.classList.remove("flex");
|
|
||||||
document.getElementById("category-form").reset();
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
let json = await res.json();
|
|
||||||
document.getElementById("link-message").innerText = json.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
linkModal.addEventListener("click", (event) => {
|
|
||||||
if (event.target === linkModal) {
|
|
||||||
targetCategoryID = null;
|
|
||||||
linkModal.classList.add("hidden");
|
|
||||||
linkModal.classList.remove("flex");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
categoryModal.addEventListener("click", (event) => {
|
|
||||||
if (event.target === categoryModal) {
|
|
||||||
targetCategoryID = null;
|
|
||||||
categoryModal.classList.add("hidden");
|
|
||||||
categoryModal.classList.remove("flex");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "passport",
|
"name": "passport",
|
||||||
"version": "0.0.1",
|
"version": "0.3.2",
|
||||||
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
|
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
|
||||||
"author": "juls0730",
|
"author": "juls0730",
|
||||||
"license": "BSL-1.0",
|
"license": "BSL-1.0",
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
"url": "https://github.com/juls0730/passport.git"
|
"url": "https://github.com/juls0730/passport.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "go generate; PASSPORT_DEV_MODE=true go run main.go",
|
"dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go",
|
||||||
"build": "go generate && go build -tags netgo,prod -o passport"
|
"build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport src/main.go"
|
||||||
},
|
},
|
||||||
"pattern": "**/*.go,templates/views/**/*.hbs,styles/**/*.css,assets/**/*.{svg,png,jpg,jpeg,webp,woff2,ttf,otf,eot,ico,gif,webp}"
|
"pattern": "src/**/*.{go,hbs,js,css,scss,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
|
||||||
|
"shutdown_signal": "SIGINT"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user