├── .air.toml ├── .dockerignore ├── .env.example ├── .envrc ├── .fleet └── settings.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── dev.yml │ ├── janitor.yml │ ├── playwright.yml │ ├── pr-validation.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .idea ├── .gitignore ├── modules.xml ├── vcs.xml └── wga_pb.iml ├── .licrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── SECURITY.md ├── assets ├── internals.go ├── public.go ├── public │ ├── 404.html │ ├── browserconfig.xml │ ├── css │ │ └── vendor │ │ │ ├── cookieconsent.css │ │ │ └── viewer.min.css │ ├── images │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── smo_cover_1080x1080.png │ ├── js │ │ └── vendor │ │ │ ├── .gitignore │ │ │ ├── cookieconsent-init.js │ │ │ └── cookieconsent.js │ ├── music │ │ └── anonymous_conductus.mp3 │ └── site.webmanifest ├── reference │ ├── art_periods.json │ ├── artists_with_bio_stage_2.json.zst │ ├── artworks_stage_2.json.zst │ ├── complementary_artists.json │ ├── forms.json │ ├── glossary_stage_1.json │ ├── musics.json │ ├── schools.json │ ├── static_content.json │ ├── strings.json │ ├── types.json │ ├── wga_placeholder_landscape.jpg │ └── wga_placeholder_portrait.jpg ├── templ │ ├── components │ │ ├── feedback.templ │ │ ├── footer.templ │ │ ├── image.templ │ │ ├── nav.templ │ │ └── postcard.templ │ ├── dto │ │ └── dto.go │ ├── error_pages │ │ ├── bad_request.templ │ │ ├── not_found.templ │ │ └── server_fault.templ │ ├── layouts │ │ └── layout.templ │ ├── pages │ │ ├── artist.templ │ │ ├── artists.templ │ │ ├── artwork.templ │ │ ├── artworks.templ │ │ ├── contributors.templ │ │ ├── guestbook.templ │ │ ├── home.templ │ │ ├── inspire.templ │ │ ├── postcard.templ │ │ └── static.templ │ └── utils │ │ └── utils.go └── views │ ├── emails │ └── postcard.html │ ├── layouts │ ├── layout.html │ └── noLayout.html │ └── pages │ ├── musics.html │ ├── musics │ └── music.html │ └── postcard.html ├── build.js ├── build.sh ├── bun.lockb ├── bunfig.toml ├── crontab ├── main.go ├── postcard.go ├── postcard_test.go └── sitemap.go ├── devenv.local.stub.nix ├── devenv.lock ├── devenv.nix ├── devenv.yaml ├── docker-compose.yml ├── docs └── searchpage.md ├── errs ├── form.go └── honeypot.go ├── fly.toml ├── go.mod ├── go.sum ├── handlers ├── artist.go ├── artist_test.go ├── artists.go ├── artworks │ ├── filters.go │ ├── getters.go │ └── main.go ├── contributors.go ├── feedback │ └── main.go ├── guestbook.go ├── guestbook │ └── main.go ├── home.go ├── inspire │ └── main.go ├── main.go ├── musics.go ├── postcards │ ├── main.go │ ├── save.go │ ├── send.go │ └── view.go ├── static.go └── utils.go ├── hooks ├── main.go └── strings.go ├── mailpit.log ├── main.go ├── migrations ├── 1687801090_initial_settings.go ├── 1695117058_strings_table.go ├── 1695699035_add_schools_table.go ├── 1695699036_artists_table.go ├── 1695699092_glossary_table.go ├── 1695699127_guestbook_table.go ├── 1695700169_default_admin.go ├── 1696390260_add_art_periods_table.go ├── 1696400261_add_art_forms_table.go ├── 1696479339_add_complementary_artists.go ├── 1696479673_add_art_types.go ├── 1696479790_add_art_table.go ├── 1697169726_update_settings.go ├── 1697514430_create_postcards_table.go ├── 1697713164_create_feedbacks_table.go ├── 1698222668_create_static_pages_table.go └── 1698736507_add_music_tables.go ├── models ├── artforms.go ├── artist.go ├── artperiods.go ├── arttypes.go ├── artworks.go ├── composers.go ├── feedback.go ├── glossary.go ├── guestbbok.go ├── postcards.go ├── schools.go ├── songs.go └── staticpage.go ├── musicUrls.json ├── package-lock.json ├── package.json ├── playwright-tests ├── artists.spec.ts ├── artwork-search.spec.ts ├── feedback.spec.ts ├── guestbook.spec.ts └── postcard.spec.ts ├── playwright.config.ts ├── postcss.config.js ├── resources ├── css │ └── style.pcss ├── js │ └── app.ts └── mjml │ └── postcard_notification.mjml ├── tailwind.config.js ├── utils ├── htmx.go ├── jsonld │ ├── main.go │ └── types.go ├── listMusicUrls.go ├── main.go ├── middleware.go ├── pagination.go ├── seed │ └── images.go ├── sitemap │ └── main.go ├── url.go ├── url │ └── main.go └── zst.go └── yarn.lock /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 600 10 | exclude_dir = [ 11 | "tmp", 12 | "vendor", 13 | "testdata", 14 | "resources", 15 | "dist", 16 | ".idea", 17 | ".github", 18 | "wga_data", 19 | "wga_sitemap", 20 | "node_modules", 21 | "test-results", 22 | "playwright-report", 23 | ] 24 | exclude_file = [] 25 | exclude_regex = ["_test.go"] 26 | exclude_unchanged = false 27 | follow_symlink = false 28 | full_bin = "" 29 | include_dir = [] 30 | include_ext = ["go", "tpl", "tmpl", "html", "css", "js"] 31 | include_file = [] 32 | kill_delay = "10s" 33 | log = "build-errors.log" 34 | poll = false 35 | poll_interval = 0 36 | rerun = false 37 | rerun_delay = 500 38 | send_interrupt = false 39 | stop_on_error = false 40 | 41 | [color] 42 | app = "" 43 | build = "yellow" 44 | main = "magenta" 45 | runner = "green" 46 | watcher = "cyan" 47 | 48 | [log] 49 | main_only = false 50 | time = false 51 | 52 | [misc] 53 | clean_on_exit = false 54 | 55 | [screen] 56 | clear_on_rebuild = false 57 | keep_scroll = true 58 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | # App local data 3 | **/wga_data 4 | **/wga_sitemap 5 | **/analytics.txt 6 | **/.env 7 | **/wga 8 | 9 | # Go build related files 10 | **/dist 11 | **/tmp 12 | 13 | # VSCode can output html from mjml 14 | **/*/mjml/*.html 15 | 16 | # Front-end build files 17 | **/resources/sitebuild/out/style.css 18 | **/assets/public/css/style.css 19 | **/assets/public/js/*.js 20 | **/assets/public/js/*.map 21 | **/assets/public/js/*.txt 22 | **/assets/public/css/*.css 23 | **/assets/public/css/*.map 24 | **/assets/public/css/*.txt 25 | **/assets/public/fa-* 26 | 27 | # hosts seed data for the guestbook, can't include because of GDPR 28 | **/guestbook.json 29 | 30 | # this file is used for cache only 31 | **/contributors.json 32 | 33 | # Dependecies shouldn't be a part of the source code 34 | **/node_modules 35 | **/meta.json 36 | 37 | # Ignore templ generated files 38 | **/*_templ.go 39 | **/*_templ.txt 40 | 41 | # Ignore playwright generated files 42 | test-results 43 | playwright-report 44 | blob-report 45 | playwright/.cache 46 | 47 | # flyctl launch added from .husky/_/.gitignore 48 | .husky/_/**/* 49 | 50 | # flyctl launch added from .idea/.gitignore 51 | # Default ignored files 52 | .idea/shelf 53 | .idea/workspace.xml 54 | # Editor-based HTTP Client requests 55 | .idea/httpRequests 56 | # Datasource local storage ignored files 57 | .idea/dataSources 58 | .idea/dataSources.local.xml 59 | 60 | # flyctl launch added from assets/public/js/vendor/.gitignore 61 | assets/public/js/vendor/**/htmx.min.js 62 | assets/public/js/vendor/**/loading-states.js 63 | fly.toml 64 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | WGA_ADMIN_EMAIL=some.random.email@local.host # admin email address 2 | WGA_ADMIN_PASSWORD=VerySecurePassword # admin password 3 | 4 | WGA_S3_ENDPOINT=http://localhost:9000 # minio server in docker, change for production! 5 | WGA_S3_BUCKET=wga # default minio bucket in docker, change for production! 6 | WGA_S3_REGION= # empty for minio, change for production! 7 | WGA_S3_ACCESS_KEY=WKQPUXGRDXWUCEIGJVBZ # default minio access key in docker, change for production! 8 | WGA_S3_ACCESS_SECRET=wAeifxp7TpJy17u9fxRgJ6ONXCvZfi90qs3j9z1i # default minio secret key in docker, change for production! 9 | 10 | WGA_PROTOCOL=http # http or https 11 | WGA_HOSTNAME=localhost:8090 # hostname (and port) 12 | 13 | WGA_SMTP_HOST= # smtp server 14 | WGA_SMTP_PORT= # smtp port 15 | WGA_SMTP_USERNAME= # smtp username 16 | WGA_SMTP_PASSWORD= # smtp password 17 | WGA_SENDER_ADDRESS= # sender email address 18 | WGA_SENDER_NAME= # sender name 19 | 20 | MAILPIT_URL=http://127.0.0.1:8025 # mailpit url 21 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k=" 2 | 3 | use devenv 4 | -------------------------------------------------------------------------------- /.fleet/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "backend.maxHeapSizeMb": 1487, 3 | "editor.formatOnSave": true, 4 | "plugins": [ 5 | { 6 | "type": "add", 7 | "pluginName": "fleet.go" 8 | }, 9 | { 10 | "type": "add", 11 | "pluginName": "fleet.git.frontend" 12 | }, 13 | { 14 | "type": "add", 15 | "pluginName": "fleet.grazie" 16 | }, 17 | { 18 | "type": "add", 19 | "pluginName": "fleet.xml" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [blackfyre] 4 | open_collective: web-gallery-of-art 5 | ko_fi: blackfyre 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | name: Dev Deploy 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | - "!main" 7 | jobs: 8 | deploy: 9 | name: Deploy app 10 | runs-on: ubuntu-latest 11 | concurrency: deploy-group 12 | environment: dev 13 | if: contains(github.event.head_commit.message, 'deploy-dev') 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: superfly/flyctl-actions/setup-flyctl@master 17 | - run: flyctl deploy --remote-only 18 | env: 19 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/janitor.yml: -------------------------------------------------------------------------------- 1 | name: Janitor 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 1 * * *" 6 | jobs: 7 | cleanup: 8 | permissions: 9 | actions: write 10 | contents: write 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: clean workflow runs 14 | uses: boredland/action-purge-workflow-runs@main 15 | with: 16 | days-old: 30 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | - name: Remove old artifacts 20 | uses: c-hive/gha-remove-artifacts@v1 21 | with: 22 | age: "30 days" 23 | skip-tags: true 24 | skip-recent: 5 25 | -------------------------------------------------------------------------------- /.github/workflows/pr-validation.yml: -------------------------------------------------------------------------------- 1 | name: PR Conventional Commit Validation 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, edited] 6 | 7 | jobs: 8 | validate-pr-title: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: PR Conventional Commit Validation 12 | uses: ytanikin/PRConventionalCommits@1.3.0 13 | with: 14 | task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert", "build"]' 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: basebuild 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up bun 20 | uses: oven-sh/setup-bun@v1 21 | with: 22 | bun-version: latest 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v3 26 | with: 27 | go-version: ">=1.22.0" 28 | 29 | - name: Install a-h/templ 30 | run: go install github.com/a-h/templ/cmd/templ@latest 31 | 32 | - name: Run GoReleaser 33 | uses: goreleaser/goreleaser-action@v3 34 | with: 35 | distribution: goreleaser 36 | version: latest 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | deploy_uat: 41 | runs-on: ubuntu-latest 42 | needs: goreleaser 43 | environment: uat 44 | steps: 45 | - name: executing remote ssh commands using ssh key 46 | uses: appleboy/ssh-action@v1.0.0 47 | with: 48 | host: ${{ secrets.DEPLOY_TARGET_HOST }} 49 | username: ${{ secrets.DEPLOY_TARGET_USER }} 50 | key: ${{ secrets.DEPLOY_TARGET_KEY }} 51 | port: ${{ secrets.DEPLOY_TARGET_PORT }} 52 | passphrase: ${{ secrets.DEPLOY_TARGET_PASSPHRASE }} 53 | script: ./update-wga.sh ${{github.ref_name}} delete_wga_data 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App local data 2 | wga_data 3 | wga_sitemap 4 | analytics.txt 5 | .env 6 | .env.dev 7 | .env.uat 8 | wga 9 | 10 | # Go build related files 11 | dist/ 12 | tmp/ 13 | 14 | # VSCode can output html from mjml 15 | */mjml/*.html 16 | 17 | # Front-end build files 18 | resources/sitebuild/out/style.css 19 | assets/public/css/style.css 20 | assets/public/js/*.js 21 | assets/public/js/*.map 22 | assets/public/js/*.txt 23 | assets/public/css/*.css 24 | assets/public/css/*.map 25 | assets/public/css/*.txt 26 | assets/public/fa-* 27 | 28 | # hosts seed data for the guestbook, can't include because of GDPR 29 | guestbook.json 30 | 31 | # this file is used for cache only 32 | contributors.json 33 | 34 | # Dependecies shouldn't be a part of the source code 35 | node_modules 36 | meta.json 37 | 38 | # Ignore templ generated files 39 | *_templ.go 40 | *_templ.txt 41 | 42 | # Ignore playwright generated files 43 | /test-results/ 44 | /playwright-report/ 45 | /blob-report/ 46 | /playwright/.cache/ 47 | 48 | .direnv# Devenv 49 | .devenv* 50 | devenv.local.nix 51 | 52 | # direnv 53 | .direnv 54 | 55 | # pre-commit 56 | .pre-commit-config.yaml 57 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: Web Gallery of Art 4 | 5 | report_sizes: true 6 | 7 | before: 8 | hooks: 9 | - bun install 10 | - templ generate 11 | - go mod tidy 12 | - bun run build:css 13 | - bun run build:js 14 | 15 | builds: 16 | - env: 17 | - CGO_ENABLED=0 18 | goos: 19 | - linux 20 | goarch: 21 | - amd64 22 | 23 | archives: 24 | - format: tar.gz 25 | name_template: >- 26 | {{ .ProjectName }}_ 27 | {{- title .Os }}_ 28 | {{- if eq .Arch "amd64" }}x86_64 29 | {{- else if eq .Arch "386" }}i386 30 | {{- else }}{{ .Arch }}{{ end }} 31 | {{- if .Arm }}v{{ .Arm }}{{ end }} 32 | format_overrides: 33 | - goos: windows 34 | format: zip 35 | 36 | changelog: 37 | sort: asc 38 | filters: 39 | exclude: 40 | - "^docs:" 41 | - "^test:" 42 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/wga_pb.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.licrc: -------------------------------------------------------------------------------- 1 | # IMPORTANT!: ALL SECTIONS ARE MANDATORY 2 | [licenses] 3 | # This indicates which are the only licenses that Licensebat will accept. 4 | # The rest will be flagged as not allowed. 5 | accepted = ["MIT", "MSC", "BSD"] 6 | # This will indicate which licenses are not accepted. 7 | # The rest will be accepted, except for the unknown licenses or dependencies without licenses. 8 | # unaccepted = ["LGPL"] 9 | # Note that only one of the previous options can be enabled at once. 10 | # If both of them are informed, only accepted will be considered. 11 | 12 | [dependencies] 13 | # This will allow users to flag some dependencies so that Licensebat will not check for their license. 14 | ignored=["ignored_dep1", "ignored_dep2"] 15 | # If set to true, Licensebat will ignore the dev dependencies. 16 | ignore_dev_dependencies = true 17 | # If set to true, Licensebat will ignore the optional dependencies. 18 | ignore_optional_dependencies = true 19 | 20 | [behavior] 21 | # False by default, if true, it will only run the checks when one of the dependency files or the .licrc file has been modified. 22 | run_only_on_dependency_modification = true 23 | # False by default, if true, it will never block the build. 24 | do_not_block_pr = true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # App local data 2 | wga_data 3 | wga_sitemap 4 | analytics.txt 5 | .env 6 | 7 | # Go build related files 8 | dist/ 9 | tmp/ 10 | 11 | # VSCode can output html from mjml 12 | */mjml/*.html 13 | 14 | # Front-end build files 15 | resources/sitebuild/out/style.css 16 | assets/* 17 | 18 | # Dependecies shouldn't be a part of the source code 19 | node_modules 20 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "mjmlio.vscode-mjml", 4 | "esbenp.prettier-vscode", 5 | "alexcvzz.vscode-sqlite", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "cwd": "${workspaceFolder}", 13 | "program": "${workspaceFolder}", 14 | "args": ["serve"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/*_templ.go": false, 4 | "**/*_templ.txt": true 5 | }, 6 | "editor.formatOnSave": true, 7 | "[templ]": { 8 | "editor.defaultFormatter": "a-h.templ" 9 | }, 10 | "emmet.includeLanguages": { 11 | "templ": "html" 12 | }, 13 | "cSpell.words": [ 14 | "Artform", 15 | "Automigrate", 16 | "awid", 17 | "bluemonday", 18 | "bulma", 19 | "cachix", 20 | "Cloners", 21 | "cssnano", 22 | "daisyui", 23 | "daos", 24 | "devenv", 25 | "esbuild", 26 | "fontawesome", 27 | "fsys", 28 | "Galicz", 29 | "goarch", 30 | "godotenv", 31 | "Goreleaser", 32 | "Htmx", 33 | "isaack", 34 | "joho", 35 | "koedijck", 36 | "labstack", 37 | "Lexend", 38 | "Mailpit", 39 | "metafile", 40 | "migratecmd", 41 | "Miklós", 42 | "nixos", 43 | "nixpkgs", 44 | "onclick", 45 | "outbase", 46 | "outdir", 47 | "pcss", 48 | "pocketbase", 49 | "Recaptcha", 50 | "sabloger", 51 | "tailwindcss", 52 | "templ", 53 | "tmpl", 54 | "trix", 55 | "Upsert", 56 | "viewerjs" 57 | ], 58 | "codeQL.githubDatabase.download": "never" 59 | } 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1.23.3 2 | FROM oven/bun:alpine AS bun-builder 3 | 4 | RUN apk add git 5 | WORKDIR /app/src 6 | COPY . . 7 | RUN bun install 8 | RUN bun run build 9 | 10 | FROM golang:${GO_VERSION}-alpine AS go-builder 11 | 12 | RUN echo "Building with Go version ${GO_VERSION}" 13 | 14 | WORKDIR /app/src 15 | COPY --from=bun-builder /app/src /app/src 16 | RUN go mod download && go mod verify 17 | RUN go install github.com/a-h/templ/cmd/templ@latest 18 | RUN templ generate 19 | RUN go mod tidy 20 | RUN go build -v -o /tmp/app . 21 | 22 | 23 | FROM alpine:latest 24 | 25 | COPY --from=go-builder /tmp/app /usr/local/bin/ 26 | EXPOSE 8090 27 | CMD ["app", "serve", "--http", "0.0.0.0:8090"] 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Galicz Miklós 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Reporting Potential Security Issues 4 | 5 | If you have encountered a potential security vulnerability in this project, 6 | please report it to us at . We will work with you to 7 | verify the vulnerability and patch it. 8 | 9 | When reporting issues, please provide the following information: 10 | 11 | - Component(s) affected 12 | - A description indicating how to reproduce the issue 13 | - A summary of the security vulnerability and impact 14 | 15 | We request that you contact us via the email address above and give the 16 | project contributors a chance to resolve the vulnerability and issue a new 17 | release prior to any public exposure; this helps protect the project's 18 | users, and provides them with a chance to upgrade and/or update in order to 19 | protect their applications. 20 | 21 | ## Policy 22 | 23 | If we verify a reported security vulnerability, our policy is: 24 | 25 | - We will patch the current release branch, as well as the immediate prior minor 26 | release branch. 27 | 28 | - After patching the release branches, we will immediately issue new security 29 | fix releases for each patched release branch. 30 | -------------------------------------------------------------------------------- /assets/internals.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "html/template" 7 | "log" 8 | 9 | "github.com/blackfyre/wga/utils" 10 | "github.com/pocketbase/pocketbase/apis" 11 | ) 12 | 13 | //go:embed "reference/*" "views/*" 14 | var InternalFiles embed.FS 15 | 16 | type Renderable struct { 17 | IsHtmx bool 18 | Page string 19 | Block string 20 | Data map[string]any 21 | } 22 | 23 | func RenderPageWithLayout(t string, layout string, data map[string]any) (string, error) { 24 | 25 | patterns := []string{ 26 | "views/layouts/*.html", 27 | "views/partials/*.html", 28 | } 29 | 30 | patterns = append(patterns, "views/pages/"+t+".html") 31 | 32 | return renderHtml(patterns, layout, data) 33 | } 34 | 35 | // RenderBlock renders a given block of HTML using the provided data and returns the resulting HTML string. 36 | // The function searches for HTML templates in the "views/pages" and "views/partials" directories of the InternalFiles filesystem. 37 | // It uses the utils.TemplateFuncs map to provide additional functions to the templates. 38 | // If an error occurs while parsing or rendering the template, the function returns an empty string and the error. 39 | func RenderBlock(block string, data map[string]any) (string, error) { 40 | 41 | patterns := []string{ 42 | "views/pages/*.html", 43 | "views/pages/*/*.html", 44 | "views/partials/*.html", 45 | } 46 | 47 | return renderHtml(patterns, block, data) 48 | } 49 | 50 | // RenderEmail renders an email template with the given data. 51 | // The function takes a string `t` representing the template name and a map `data` containing the data to be rendered. 52 | // It returns a string representing the rendered email and an error if any occurred. 53 | func RenderEmail(t string, data map[string]any) (string, error) { 54 | 55 | patterns := []string{ 56 | "views/emails/*.html", 57 | } 58 | 59 | return renderHtml(patterns, t, data) 60 | } 61 | 62 | // renderHtml renders an HTML template using the provided patterns, name and data. 63 | // It returns the rendered HTML as a string and an error if any occurred. 64 | func renderHtml(patterns []string, name string, data map[string]any) (string, error) { 65 | 66 | ts, err := template.New("").Funcs(utils.TemplateFuncs).ParseFS( 67 | InternalFiles, 68 | patterns..., 69 | ) 70 | 71 | if err != nil { 72 | log.Println("Error parsing template") 73 | log.Println(err) 74 | return "", err 75 | } 76 | 77 | html := new(bytes.Buffer) 78 | 79 | err = ts.ExecuteTemplate(html, name, data) 80 | 81 | if err != nil { 82 | // or redirect to a dedicated 404 HTML page 83 | log.Println("Error rendering template") 84 | log.Println(err) 85 | return "", apis.NewNotFoundError("", err) 86 | } 87 | 88 | return html.String(), nil 89 | } 90 | -------------------------------------------------------------------------------- /assets/public.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed "public/*" 8 | var PublicFiles embed.FS 9 | -------------------------------------------------------------------------------- /assets/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 404 Error - Page Not Found 9 | 10 | 11 | 12 |

404 Error - Page Not Found

13 |

The page you are looking for could not be found. Please check the URL or go back to the homepage. 14 |

15 | 16 | 17 | -------------------------------------------------------------------------------- /assets/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #013365 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/public/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/public/images/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/android-chrome-512x512.png -------------------------------------------------------------------------------- /assets/public/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/public/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/favicon-16x16.png -------------------------------------------------------------------------------- /assets/public/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/favicon-32x32.png -------------------------------------------------------------------------------- /assets/public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/favicon.ico -------------------------------------------------------------------------------- /assets/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/logo.png -------------------------------------------------------------------------------- /assets/public/images/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/mstile-150x150.png -------------------------------------------------------------------------------- /assets/public/images/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 30 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /assets/public/images/smo_cover_1080x1080.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/images/smo_cover_1080x1080.png -------------------------------------------------------------------------------- /assets/public/js/vendor/.gitignore: -------------------------------------------------------------------------------- 1 | htmx.min.js 2 | loading-states.js -------------------------------------------------------------------------------- /assets/public/music/anonymous_conductus.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/public/music/anonymous_conductus.mp3 -------------------------------------------------------------------------------- /assets/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Web Gallery of Art", 3 | "short_name": "WGA", 4 | "icons": [ 5 | { 6 | "src": "/assets/images/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/assets/images/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#013365", 17 | "background_color": "#013365", 18 | "start_url": "/", 19 | "display": "standalone", 20 | "orientation": "portrait" 21 | } -------------------------------------------------------------------------------- /assets/reference/artists_with_bio_stage_2.json.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/reference/artists_with_bio_stage_2.json.zst -------------------------------------------------------------------------------- /assets/reference/artworks_stage_2.json.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/reference/artworks_stage_2.json.zst -------------------------------------------------------------------------------- /assets/reference/forms.json: -------------------------------------------------------------------------------- 1 | [{"id":"2875ec54afbd992","name":"Painting"},{"id":"2d242bb36ec91b3","name":"Architecture"},{"id":"8936e4678a12fed","name":"Ceramics"},{"id":"817e896c4caf2cf","name":"Sculpture"},{"id":"d334dfcea59127b","name":"Graphics"},{"id":"cac12c54b5128e6","name":"Stained-Glass"},{"id":"93bbc3aa05d108d","name":"Metalwork"},{"id":"a4869d0be7226ce","name":"Illumination"},{"id":"bb3a8b2e3901420","name":"Furniture"},{"id":"2252d763a2a4ac8","name":"Mosaic"},{"id":"52ef9633d88a748","name":"Others"},{"id":"6a0aee82c9817d5","name":"Tapestry"},{"id":"e10d4bba49524c6","name":"Glassware"}] -------------------------------------------------------------------------------- /assets/reference/schools.json: -------------------------------------------------------------------------------- 1 | [{"id":"c6c6f067a3ead9c","name":"American"},{"id":"6112a10894125bd","name":"Austrian"},{"id":"269a63fec5e91f6","name":"Belgian"},{"id":"c77cbc5a72a5cc8","name":"Bohemian"},{"id":"900a14b115614d9","name":"Catalan"},{"id":"7bc6f150ce738db","name":"Danish"},{"id":"68bf367e228f45b","name":"Dutch"},{"id":"78463a384a5aa4f","name":"English"},{"id":"9ae099fd082267f","name":"Finnish"},{"id":"4c3e73936ee49ac","name":"Flemish"},{"id":"ad225f707802ba1","name":"French"},{"id":"86bc3115eb4e987","name":"German"},{"id":"0aafa497807d5ac","name":"Greek"},{"id":"7b86112ec6401fd","name":"Hungarian"},{"id":"2cfce796f4703d5","name":"Irish"},{"id":"4be8e06d27bca7e","name":"Italian"},{"id":"f8072031fdc5aa7","name":"Netherlandish"},{"id":"da550ca06bcacbd","name":"Norwegian"},{"id":"6311ae17c1ee52b","name":"Other"},{"id":"c730389bc8d99e5","name":"Polish"},{"id":"30e32c7c4cf434e","name":"Portuguese"},{"id":"deba6920e706154","name":"Russian"},{"id":"a1b27c48dae43b9","name":"Scottish"},{"id":"cb5480c32e71778","name":"Spanish"},{"id":"41171a0fcd362ce","name":"Swedish"},{"id":"020f758800fd8d1","name":"Swiss"}] -------------------------------------------------------------------------------- /assets/reference/types.json: -------------------------------------------------------------------------------- 1 | [{"id":"c07ca8d777cae2a","name":"Mythological"},{"id":"dff1dc0aa9a196a","name":"Historical"},{"id":"7f80095aea4d66a","name":"Genre"},{"id":"ad9b8a7b14ee534","name":"Portrait"},{"id":"992763c3260eacf","name":"Landscape"},{"id":"b9bba0357bc3fa8","name":"Religious"},{"id":"795f3202b17cb6b","name":"Other"},{"id":"b79de4fb00edbed","name":"Interior"},{"id":"1ec2016608eb4eb","name":"Still-Life"},{"id":"2cd1c6ecec2c6d9","name":"Study"}] -------------------------------------------------------------------------------- /assets/reference/wga_placeholder_landscape.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/reference/wga_placeholder_landscape.jpg -------------------------------------------------------------------------------- /assets/reference/wga_placeholder_portrait.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/assets/reference/wga_placeholder_portrait.jpg -------------------------------------------------------------------------------- /assets/templ/components/feedback.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ FeedbackForm() { 4 |
5 |

Are we doing good?

6 |

Please, share your observations with us!

7 | 12 |
20 |
21 | 22 | 31 | 32 |
33 | 34 | 43 | 44 | 53 |
54 | 57 | 60 |
61 |
62 |
63 | } 64 | -------------------------------------------------------------------------------- /assets/templ/components/footer.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ Footer() { 4 | 70 | } 71 | -------------------------------------------------------------------------------- /assets/templ/components/image.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "github.com/blackfyre/wga/assets/templ/dto" 4 | 5 | templ ImageBase(i dto.Image) { 6 |
7 | 8 | 9 | 10 | 11 | { 12 | 13 |
{ i.Title } by { i.Artist.Name }
14 |
15 | } 16 | 17 | // image_big is a template that renders a big image with its title and artist. 18 | // It takes three parameters: ImageUrl (string) - the URL of the image, 19 | // Title (string) - the title of the image, and Artist (string) - the artist of the image. 20 | templ ImageBig(ImageUrl string, Title string, Artist string) { 21 |
22 | { 23 |
{ Title } by { Artist }
24 |
25 | } 26 | 27 | templ ImageCard(i dto.Image, hasLearnMore bool) { 28 |
29 | @ImageBase(i) 30 |
31 |

{ i.Title }

32 |

{ i.Artist.Name }

33 |
34 | @templ.Raw(i.Comment) 35 |
36 | 54 |
55 |
56 | } 57 | 58 | templ ImageGridComponent(i dto.ImageGrid, hasLearnMore bool) { 59 |
60 | for _, img := range i { 61 | @ImageCard(img, hasLearnMore) 62 | } 63 |
64 | } 65 | -------------------------------------------------------------------------------- /assets/templ/components/nav.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | templ TopNav() { 4 | 65 | } 66 | -------------------------------------------------------------------------------- /assets/templ/components/postcard.templ: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | type PostcardEditorDTO struct { 4 | ImageId string 5 | Image string 6 | Title string 7 | Technique string 8 | Comment string 9 | AuthorName string 10 | } 11 | 12 | templ PostcardEditor(p PostcardEditorDTO) { 13 |
14 | 19 |

Write a postcard

20 |
21 |
22 |
{
23 |

{ p.Title }

24 |

{ p.AuthorName }

25 |
26 | @templ.Raw(p.Comment) 27 |
28 |
29 |
30 |
37 | 38 |
39 | 51 | 63 |
64 |
65 | 66 |
67 |
68 | 85 |
86 |
87 | 91 |
92 |
93 | 94 |
95 | 96 | 97 |
98 |
99 |
100 | 104 |
105 | 106 | 115 | 116 | 125 |
126 | 129 | 132 |
133 |
134 |
135 |
136 |
137 | } 138 | -------------------------------------------------------------------------------- /assets/templ/dto/dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type Image struct { 4 | Thumb string 5 | Image string 6 | Title string 7 | Technique string 8 | Comment string 9 | Url string 10 | Id string 11 | Jsonld interface{} 12 | Artist 13 | } 14 | 15 | type ImageGrid []Image 16 | 17 | type Artist struct { 18 | Id string 19 | Name string 20 | BornDied string 21 | Schools string 22 | Profession string 23 | Url string 24 | BioExcerpt string 25 | Jsonld string 26 | Bio string 27 | Works ImageGrid 28 | } 29 | 30 | type ArtistsView struct { 31 | Count string 32 | Artists []Artist 33 | Pagination string 34 | Jsonld string 35 | QueryStr string 36 | } 37 | 38 | type Artwork struct { 39 | Id string 40 | Title string 41 | Comment string 42 | Technique string 43 | Jsonld string 44 | Url string 45 | Image 46 | Artist 47 | } 48 | 49 | type ArtworkSearchDTO struct { 50 | ArtFormOptions map[string]string 51 | ArtTypeOptions map[string]string 52 | ArtSchoolOptions map[string]string 53 | ActiveFilterValues *ArtworkSearchFilterValues 54 | ArtistNameList []string 55 | NewFilterValues string 56 | Results ArtworkSearchResultDTO 57 | } 58 | 59 | type ArtworkSearchFilterValues struct { 60 | ArtFormString string 61 | ArtTypeString string 62 | SchoolString string 63 | Title string 64 | ArtistString string 65 | } 66 | 67 | type ArtworkSearchResultDTO struct { 68 | ActiveFiltering bool 69 | Artworks ImageGrid 70 | Pagination string 71 | } -------------------------------------------------------------------------------- /assets/templ/error_pages/bad_request.templ: -------------------------------------------------------------------------------- 1 | package error_pages 2 | 3 | import "github.com/blackfyre/wga/assets/templ/layouts" 4 | 5 | templ BadRequestPage() { 6 | @layouts.LayoutMain() { 7 | 8 | 400 - Server Fault 9 | 10 | @ServerFaultBlock() 11 | } 12 | } 13 | 14 | templ BadRequestBlock() { 15 |
16 |
17 |
18 |

19 | Looks like you've wanted something that's not supported! 20 |

21 |

Sorry about that!

22 |

Please try again a couple more times to drive the point home!

23 |
24 |
25 | 26 |
27 |
28 |
29 | } 30 | -------------------------------------------------------------------------------- /assets/templ/error_pages/not_found.templ: -------------------------------------------------------------------------------- 1 | package error_pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/layouts" 5 | ) 6 | 7 | templ NotFoundPage() { 8 | @layouts.LayoutMain() { 9 | 10 | 404 - Content not found! 11 | 12 | @NotFoundBlock() 13 | } 14 | } 15 | 16 | templ NotFoundBlock() { 17 |
18 |
19 |
20 |

21 | Looks like you've found the 22 | doorway to the great nothing 23 |

24 |

Sorry about that! Please visit our hompage to get where you need to go.

25 | Take me there! 26 |
27 |
28 | 29 |
30 |
31 |
32 | } 33 | -------------------------------------------------------------------------------- /assets/templ/error_pages/server_fault.templ: -------------------------------------------------------------------------------- 1 | package error_pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/layouts" 5 | ) 6 | 7 | templ ServerFaultPage() { 8 | @layouts.LayoutMain() { 9 | 10 | 500 - Server Fault 11 | 12 | @ServerFaultBlock() 13 | } 14 | } 15 | 16 | templ ServerFaultBlock() { 17 |
18 |
19 |
20 |

21 | Looks like you've found something that's really broken! 22 |

23 |

Sorry about that!

24 |

Please try again a couple more times to drive the point home!

25 |
26 |
27 | 28 |
29 |
30 |
31 | } 32 | -------------------------------------------------------------------------------- /assets/templ/layouts/layout.templ: -------------------------------------------------------------------------------- 1 | package layouts 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/components" 5 | "github.com/blackfyre/wga/assets/templ/utils" 6 | ) 7 | 8 | templ layout_base() { 9 | 10 | 11 | 12 | 13 | 14 | { utils.GetTitle(ctx) } 15 | 16 | for key, content := range utils.GetOpenGraphTags(ctx) { 17 | 18 | } 19 | for key, content := range utils.GetTwitterTags(ctx) { 20 | 21 | } 22 | if utils.GetCanonicalUrl(ctx) != "" { 23 | 24 | } 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 |
44 | { children... } 45 | 46 | Feedback 55 |
56 | 57 | 58 | 59 | 60 | 61 | } 62 | 63 | templ LayoutMain() { 64 | @layout_base() { 65 | @components.TopNav() 66 | if utils.GetEnvironment(ctx) != "production" { 67 |
68 | 77 |
78 | } 79 |
80 | { children... } 81 |
82 | @components.Footer() 83 |
84 | 85 | 86 | 87 |
88 | } 89 | } 90 | 91 | templ LayoutSlim() { 92 | @layout_base() { 93 |
94 | { children... } 95 |
96 | } 97 | } 98 | -------------------------------------------------------------------------------- /assets/templ/pages/artist.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/dto" 5 | "github.com/blackfyre/wga/assets/templ/layouts" 6 | "github.com/blackfyre/wga/assets/templ/utils" 7 | "github.com/blackfyre/wga/assets/templ/components" 8 | ) 9 | 10 | // ArtistPage is the template for the artist page 11 | templ ArtistPage(c dto.Artist) { 12 | @layouts.LayoutMain() { 13 | @ArtistBlock(c) 14 | } 15 | } 16 | 17 | // ArtistsBlock is the template for the artist block 18 | templ ArtistBlock(a dto.Artist) { 19 | 20 | { utils.GetTitle(ctx) } 21 | 22 |
23 | 53 |
54 | 55 |
56 |
57 |

58 | { a.Name } 59 |

60 |
61 |
62 | ({ a.BioExcerpt }) 63 |
64 |
65 |
66 | @templ.Raw(a.Bio) 67 |
68 |
69 | @components.ImageGridComponent(a.Works, true) 70 | @templ.Raw(a.Jsonld) 71 |
72 | } 73 | -------------------------------------------------------------------------------- /assets/templ/pages/artists.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/dto" 5 | "github.com/blackfyre/wga/assets/templ/layouts" 6 | "github.com/blackfyre/wga/assets/templ/utils" 7 | "regexp" 8 | ) 9 | 10 | templ ArtistsPageFull(c dto.ArtistsView) { 11 | @layouts.LayoutMain() { 12 | @ArtistsPageBlock(c) 13 | } 14 | } 15 | 16 | func HighlightArtistName(name string, query string) string { 17 | if query == "" { 18 | return name 19 | } 20 | re := regexp.MustCompile(`(?mis)` + regexp.QuoteMeta(query)) 21 | return re.ReplaceAllStringFunc(name, func(s string) string { 22 | return "" + s + "" 23 | }) 24 | } 25 | 26 | templ ArtistsPageBlock(c dto.ArtistsView) { 27 | 28 | { utils.GetTitle(ctx) } 29 | 30 |
31 | 53 |
54 |
55 |

{ c.Count } artists

56 |
57 |
58 | 73 |
74 |
75 | @ArtistsSearchResults(c) 76 |
77 | } 78 | 79 | templ artistsTable(list []dto.Artist, query string) { 80 |
81 | 82 | 83 | 84 | 87 | 90 | 93 | 96 | 97 | 98 | 99 | for _, a := range list { 100 | 101 | 108 | 109 | 110 | 111 | 112 | } 113 | 114 | 115 | 116 | 119 | 122 | 125 | 128 | 129 | 130 |
85 | ARTIST 86 | 88 | BORN-DIED 89 | 91 | School(s) 92 | 94 | Profession 95 |
102 | 103 | 104 | @templ.Raw(HighlightArtistName(a.Name, query)) 105 | 106 | 107 | { a.BornDied }{ a.Schools }{ a.Profession }
117 | ARTIST 118 | 120 | BORN-DIED 121 | 123 | PERIOD 124 | 126 | SCHOOL 127 |
131 |
132 | } 133 | 134 | templ ArtistsSearchResults(c dto.ArtistsView) { 135 |
136 |
137 | @artistsTable(c.Artists, c.QueryStr) 138 |
139 | 142 | // {{range .Content}} 143 | @templ.Raw(c.Jsonld) 144 | // {{end}} 145 |
146 | } 147 | -------------------------------------------------------------------------------- /assets/templ/pages/artwork.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/components" 5 | "github.com/blackfyre/wga/assets/templ/dto" 6 | "github.com/blackfyre/wga/assets/templ/layouts" 7 | "github.com/blackfyre/wga/assets/templ/utils" 8 | ) 9 | 10 | templ ArtworkPage(aw dto.Artwork) { 11 | @layouts.LayoutMain() { 12 | @ArtworkBlock(aw) 13 | } 14 | } 15 | 16 | templ ArtworkBlock(aw dto.Artwork) { 17 | 18 | { utils.GetTitle(ctx) } 19 | 20 |
21 |
22 | 60 |
61 |
62 | @components.ImageBig(aw.Image.Image, aw.Image.Title, aw.Artist.Name) 63 |
64 | 88 |
89 |
90 |
91 | @templ.Raw(aw.Jsonld) 92 | } 93 | -------------------------------------------------------------------------------- /assets/templ/pages/home.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/layouts" 5 | "github.com/blackfyre/wga/assets/templ/utils" 6 | ) 7 | 8 | type HomePage struct { 9 | Content string 10 | ArtistCount string 11 | ArtworkCount string 12 | } 13 | 14 | templ HomePageWrapped(c HomePage) { 15 | @layouts.LayoutMain() { 16 | @HomePageContent(c) 17 | } 18 | } 19 | 20 | templ HomePageContent(c HomePage) { 21 | 22 | { utils.GetTitle(ctx) } 23 | 24 |
25 |
26 | @templ.Raw(c.Content) 27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 |
35 |
Artists
36 |
{ c.ArtistCount }
37 | //
Jan 1st - Feb 1st
38 |
39 |
40 |
41 | 42 |
43 |
Artworks
44 |
{ c.ArtworkCount }
45 | //
↗︎ 400 (22%)
46 |
47 |
48 |
49 | } 50 | -------------------------------------------------------------------------------- /assets/templ/pages/inspire.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/dto" 5 | "github.com/blackfyre/wga/assets/templ/layouts" 6 | "github.com/blackfyre/wga/assets/templ/utils" 7 | "github.com/blackfyre/wga/assets/templ/components" 8 | ) 9 | 10 | templ InspirePage(c dto.ImageGrid) { 11 | @layouts.LayoutMain() { 12 | @InspirationContent(c) 13 | } 14 | } 15 | 16 | templ InspirationContent(c dto.ImageGrid) { 17 | 18 | { utils.GetTitle(ctx) } 19 | 20 |
21 |

Random inspiration

22 |

This is a random selection from the database to inspire you! If you need new inspiration, just hit refresh!

23 | @components.ImageGridComponent(c, true) 24 |
25 | } 26 | -------------------------------------------------------------------------------- /assets/templ/pages/postcard.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/layouts" 5 | "github.com/blackfyre/wga/assets/templ/components" 6 | ) 7 | 8 | type PostcardView struct { 9 | Message string 10 | Image string 11 | Title string 12 | Comment string 13 | Technique string 14 | Author string 15 | SenderName string 16 | } 17 | 18 | templ PostcardPage(p PostcardView) { 19 | @layouts.LayoutMain() { 20 | @PostcardBlock(p) 21 | } 22 | } 23 | 24 | templ PostcardBlock(p PostcardView) { 25 |
26 |
27 |
28 |
29 |
30 |
31 | @components.ImageBig(p.Image, p.Title, p.Author) 32 |
33 |
34 |
35 |

{ p.Title }

36 |

{ p.Technique }

37 |
38 | @templ.Raw(p.Comment) 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | @templ.Raw(p.Message) 47 |
48 |
49 |
50 |
51 |
52 | } 53 | -------------------------------------------------------------------------------- /assets/templ/pages/static.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/blackfyre/wga/assets/templ/layouts" 5 | "github.com/blackfyre/wga/assets/templ/utils" 6 | ) 7 | 8 | type StaticPageDTO struct { 9 | Title string 10 | Content string 11 | Url string 12 | } 13 | 14 | templ StaticPage(sp StaticPageDTO) { 15 | @layouts.LayoutMain() { 16 | 17 | { utils.GetTitle(ctx) } 18 | 19 | @StaticPageBlock(sp) 20 | } 21 | } 22 | 23 | templ StaticPageBlock(sp StaticPageDTO) { 24 |
25 | 47 |
48 |
49 |

50 | { sp.Title } 51 |

52 | @templ.Raw(sp.Content) 53 |
54 | } 55 | -------------------------------------------------------------------------------- /assets/views/layouts/layout.html: -------------------------------------------------------------------------------- 1 | {{define "layout"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{block "title" .}}Web Gallery of Art{{end}} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {{block "head" .}} 25 | {{end}} 26 | {{safeHTML .Analytics}} 27 | 28 | 29 | 30 | 31 | {{template "partial:top-nav" .}} 32 | 33 | {{if ne .Env "production"}} 34 |
35 |
36 |
37 |
38 | This is a development version of the site. It is not intended for public use. You can give us a 39 | helping 40 | hand 41 | at GitHub. 42 |
43 |
44 |
45 |
46 | {{end}} 47 | 48 |
49 | {{block "body" .}} 50 | 51 | {{end}} 52 |
53 | 54 | {{template "partial:footer" .}} 55 | 56 |
57 | 58 | 59 | 60 |
61 | 66 | 67 | Feedback 69 | 70 | 71 | 72 | 73 | 74 | {{block "scripts" .}} 75 | {{end}} 76 | 77 | 78 | 79 | {{end}} -------------------------------------------------------------------------------- /assets/views/layouts/noLayout.html: -------------------------------------------------------------------------------- 1 | {{define "noLayout"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{block "title" .}}Web Gallery of Art{{end}} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {{block "head" .}} 24 | {{end}} 25 | 26 | 27 | 28 | 29 |
30 | {{block "body" .}} 31 | 32 | {{end}} 33 |
34 | 35 | 36 | Feedback 38 | 39 | 40 | 41 | {{block "scripts" .}} 42 | {{end}} 43 | 44 | 45 | 46 | {{end}} -------------------------------------------------------------------------------- /assets/views/pages/musics.html: -------------------------------------------------------------------------------- 1 | {{block "musics" .}} 2 | 3 | {{end}} 4 | 5 | {{define "title"}} 6 | Musics 7 | {{end}} 8 | 9 | {{define "body"}} 10 | {{block "musics:content" .}} 11 |
12 |
13 |

14 | Music player 15 |

16 |

17 | Please select one of the musical pieces from the list below. Please note that this is neither a comprehensive nor a representative musical collection. Its only aim is to provide a selection of matching classical music to listen while viewing and studying the old masters' works. The musical pieces were selected in such a way that visitors could find music matching the stylistic periods from the Renaissance to Romanticism. Whenever possible we call the visitors' attention to musical pieces (mainly operas) treating the same subject as a particular painting or sculpture. For this reason the suggested listenings include music from periods different from that of the creation of the artworks. 18 |

19 |

20 | {{range .Centuries}} 21 | {{.}}th | 22 | {{end}} 23 | century 24 |

25 |
26 |
    27 |
    28 | {{range .MusicList}} 29 |
    30 |
    {{.Century}}th century
    31 | {{range .Composers}} 32 |
    33 |

    34 | {{.Name}} 35 | {{if .Date}}({{.Date}}){{end}}{{if .Date}}{{if .Language}}, {{.Language}}{{end}} 36 | {{else}} 37 | {{.Language}} 38 | {{end}}

    39 |
      40 | {{range .Songs}} 41 |
    • 42 | {{if .URL}} 43 | {{.Title}} 44 | {{end}} 45 |
    • 46 | {{end}} 47 |
    48 |
    49 | {{end}} 50 |
    51 | {{end}} 52 |
    53 |
    54 | {{end}} 55 | {{end}} -------------------------------------------------------------------------------- /assets/views/pages/musics/music.html: -------------------------------------------------------------------------------- 1 | {{block "music" .}} 2 | 3 | {{end}} 4 | 5 | {{define "title"}} 6 | Music player 7 | {{end}} 8 | 9 | {{define "body"}} 10 | {{block "music:content" .}} 11 |
    12 |
    13 |

    14 | Playing a piece by {{.Composer}} 15 |

    16 |

    17 | Title: {{.Title}}
    18 | from {{.Date}} 19 |

    20 |
    21 | 30 |
    31 |
    32 |
    33 | Back to music selection
    34 | Close window 35 |
    36 |
    37 | {{end}} 38 | {{end}} -------------------------------------------------------------------------------- /assets/views/pages/postcard.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | Postcard from {{.SenderName}} | Web Gallery of Art 3 | {{end}} 4 | 5 | {{define "body"}} 6 | {{block "postcard:content" .}} 7 |
    8 |
    9 |
    10 |
    11 |
    12 |
    13 |
    14 | {{ .AwTitle}} 15 |
    16 |
    17 |
    18 |
    19 |

    {{ .AwTitle}}

    20 |

    {{ .AwTechnique }}

    21 |
    22 | {{safeHTML .AwComment}} 23 |
    24 |
    25 |
    26 |
    27 |
    28 | 29 | 30 |
    31 |
    32 | {{safeHTML .Message}} 33 |
    34 |
    35 |
    36 |
    37 |
    38 | {{end}} 39 | {{end}} -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const sourcemap = process.env.NODE_ENV === "development" ? "none" : "external"; 2 | const minify = process.env.NODE_ENV === "development" ? false : true; 3 | 4 | console.info(`Building Frontend for ${process.env.NODE_ENV} environment`); 5 | 6 | console.info("Copying Frontend assets..."); 7 | 8 | const list = [ 9 | [ 10 | "./node_modules/htmx.org/dist/ext/loading-states.js", 11 | "./assets/public/js/vendor/loading-states.js", 12 | ], 13 | ]; 14 | 15 | for await (const [src, dest] of list) { 16 | console.info(`Copying ${src} to ${dest}`); 17 | const f = Bun.file(src); 18 | await Bun.write(f, dest); 19 | } 20 | 21 | console.info("Building Frontend..."); 22 | 23 | const build = await Bun.build({ 24 | entrypoints: ["./resources/js/app.ts"], 25 | outdir: "./assets/public/js", 26 | minify, 27 | sourcemap, 28 | target: "browser", 29 | format: "esm", 30 | splitting: true, 31 | manifest: true, 32 | }); 33 | 34 | if (!build.success) { 35 | console.error("Frontend build failed"); 36 | for (const message of build.logs) { 37 | // Bun will pretty print the message object 38 | console.error(message); 39 | } 40 | } else { 41 | console.info("Frontend build succeeded"); 42 | } 43 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This file is for CodeQl 4 | 5 | # Define a function to handle errors 6 | handle_error() { 7 | echo "An error occurred. Exiting..." 8 | exit 1 9 | } 10 | 11 | # Set the error trap 12 | trap 'handle_error' ERR 13 | 14 | if command -v templ &> /dev/null; then 15 | echo "templ command found!" 16 | else 17 | echo "templ command not found. Installing..." 18 | go install github.com/a-h/templ/cmd/templ@latest 19 | echo "templ installed successfully!" 20 | fi 21 | 22 | go env 23 | 24 | GOPATH_BIN=$(go env GOPATH)/bin 25 | export PATH=${PATH}:${GOPATH_BIN} 26 | 27 | # Run templ to generate the code 28 | echo "Generating code" 29 | templ generate 30 | 31 | echo "Fetching dependencies" 32 | go mod tidy 33 | 34 | echo "Building the app" 35 | go build -v -race ./... 36 | 37 | # If the build is successful, execute the following code 38 | echo "App built successfully!" -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blackfyre/wga/2971aae3c47e19a0cd67fe237f6530788422c735/bun.lockb -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install.lockfile] 2 | # whether to save a non-Bun lockfile alongside bun.lockb 3 | # only "yarn" is supported 4 | print = "yarn" 5 | -------------------------------------------------------------------------------- /crontab/main.go: -------------------------------------------------------------------------------- 1 | package crontab 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase" 5 | "github.com/pocketbase/pocketbase/core" 6 | "github.com/pocketbase/pocketbase/tools/cron" 7 | ) 8 | 9 | func RegisterCronJobs(app *pocketbase.PocketBase) { 10 | app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 11 | scheduler := cron.New() 12 | 13 | sendPostcards(app, scheduler) 14 | generateSiteMap(app, scheduler) 15 | 16 | scheduler.Start() 17 | 18 | return nil 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /crontab/postcard_test.go: -------------------------------------------------------------------------------- 1 | package crontab 2 | 3 | import ( 4 | "net/mail" 5 | "testing" 6 | 7 | "github.com/pocketbase/pocketbase" 8 | "github.com/pocketbase/pocketbase/tools/mailer" 9 | ) 10 | 11 | func TestSendMail(t *testing.T) { 12 | 13 | app := pocketbase.NewWithConfig(pocketbase.Config{ 14 | DefaultDataDir: "./wga_data", 15 | }) 16 | 17 | mailClient := app.NewMailClient() 18 | 19 | t.Logf("mailClient: %v", mailClient) 20 | 21 | message := &mailer.Message{ 22 | From: mail.Address{ 23 | Name: "sender", 24 | Address: "sender@example.com", 25 | }, 26 | To: []mail.Address{ 27 | { 28 | Name: "recipient", 29 | Address: "recipient@example.com", 30 | }, 31 | }, 32 | Subject: "Test Subject", 33 | HTML: "Test Body", 34 | } 35 | 36 | err := mailClient.Send(message) 37 | if err != nil { 38 | t.Errorf("sendMail returned an error: %v", err) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /crontab/sitemap.go: -------------------------------------------------------------------------------- 1 | package crontab 2 | 3 | import ( 4 | "github.com/blackfyre/wga/utils/sitemap" 5 | "github.com/pocketbase/pocketbase" 6 | "github.com/pocketbase/pocketbase/tools/cron" 7 | ) 8 | 9 | func generateSiteMap(app *pocketbase.PocketBase, scheduler *cron.Cron) { 10 | scheduler.MustAdd("sitemap", "0 0 * * *", func() { 11 | sitemap.GenerateSiteMap(app) 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /devenv.local.stub.nix: -------------------------------------------------------------------------------- 1 | { pkgs, lib, config, inputs, ... }: 2 | 3 | { 4 | services.minio.accessKey = "minio"; 5 | services.minio.secretKey = "minio123"; 6 | } 7 | -------------------------------------------------------------------------------- /devenv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devenv": { 4 | "locked": { 5 | "dir": "src/modules", 6 | "lastModified": 1745890276, 7 | "owner": "cachix", 8 | "repo": "devenv", 9 | "rev": "4b437182ffd11a3bf9140f9cffd3c34576208676", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "dir": "src/modules", 14 | "owner": "cachix", 15 | "repo": "devenv", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-compat": { 20 | "flake": false, 21 | "locked": { 22 | "lastModified": 1733328505, 23 | "owner": "edolstra", 24 | "repo": "flake-compat", 25 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "edolstra", 30 | "repo": "flake-compat", 31 | "type": "github" 32 | } 33 | }, 34 | "git-hooks": { 35 | "inputs": { 36 | "flake-compat": "flake-compat", 37 | "gitignore": "gitignore", 38 | "nixpkgs": [ 39 | "nixpkgs" 40 | ] 41 | }, 42 | "locked": { 43 | "lastModified": 1742649964, 44 | "owner": "cachix", 45 | "repo": "git-hooks.nix", 46 | "rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "cachix", 51 | "repo": "git-hooks.nix", 52 | "type": "github" 53 | } 54 | }, 55 | "gitignore": { 56 | "inputs": { 57 | "nixpkgs": [ 58 | "git-hooks", 59 | "nixpkgs" 60 | ] 61 | }, 62 | "locked": { 63 | "lastModified": 1709087332, 64 | "owner": "hercules-ci", 65 | "repo": "gitignore.nix", 66 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 67 | "type": "github" 68 | }, 69 | "original": { 70 | "owner": "hercules-ci", 71 | "repo": "gitignore.nix", 72 | "type": "github" 73 | } 74 | }, 75 | "nixpkgs": { 76 | "locked": { 77 | "lastModified": 1733477122, 78 | "owner": "cachix", 79 | "repo": "devenv-nixpkgs", 80 | "rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857", 81 | "type": "github" 82 | }, 83 | "original": { 84 | "owner": "cachix", 85 | "ref": "rolling", 86 | "repo": "devenv-nixpkgs", 87 | "type": "github" 88 | } 89 | }, 90 | "root": { 91 | "inputs": { 92 | "devenv": "devenv", 93 | "git-hooks": "git-hooks", 94 | "nixpkgs": "nixpkgs", 95 | "pre-commit-hooks": [ 96 | "git-hooks" 97 | ] 98 | } 99 | } 100 | }, 101 | "root": "root", 102 | "version": 7 103 | } 104 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { pkgs, lib, config, inputs, ... }: 2 | 3 | { 4 | 5 | # https://devenv.sh/packages/ 6 | packages = [ 7 | pkgs.git 8 | pkgs.templ 9 | ] ++ lib.optionals (!config.container.isBuilding) [ 10 | pkgs.flyctl 11 | pkgs.nil 12 | ]; 13 | 14 | # https://devenv.sh/languages/ 15 | # languages.rust.enable = true; 16 | 17 | languages.go.enable = true; 18 | languages.go.enableHardeningWorkaround = true; 19 | 20 | languages.javascript = { 21 | enable = true; 22 | bun = { 23 | enable = true; 24 | install.enable = true; 25 | }; 26 | }; 27 | 28 | services.mailhog.enable = true; 29 | 30 | services.minio.enable = true; 31 | services.minio.buckets = [ 32 | "wga" 33 | ]; 34 | 35 | enterShell = '' 36 | bun --version 37 | git --version 38 | go version 39 | ''; 40 | 41 | scripts.generate-templates.exec = "templ generate"; 42 | scripts.tidy-modules.exec = "go mod tidy"; 43 | scripts.tidy.exec = '' 44 | devenv shell generate-templates 45 | devenv shell tidy-modules 46 | ''; 47 | pre-commit.hooks = { 48 | govet = { 49 | enable = true; 50 | pass_filenames = false; 51 | }; 52 | gotest.enable = true; 53 | golangci-lint = { 54 | enable = true; 55 | pass_filenames = false; 56 | }; 57 | }; 58 | 59 | # See full reference at https://devenv.sh/reference/options/ 60 | } 61 | -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://devenv.sh/devenv.schema.json 2 | inputs: 3 | nixpkgs: 4 | url: github:cachix/devenv-nixpkgs/rolling 5 | # If you're using non-OSS software, you can set allowUnfree to true. 6 | # allowUnfree: true 7 | 8 | # If you're willing to use a package that's vulnerable 9 | # permittedInsecurePackages: 10 | # - "openssl-1.1.1w" 11 | 12 | # If you have more than one devenv you can merge them 13 | #imports: 14 | # - ./backend 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | minio: 3 | image: minio/minio 4 | ports: 5 | - 9000:9000 6 | - 9001:9001 7 | environment: 8 | - MINIO_ROOT_USER=${WGA_S3_ACCESS_KEY} 9 | - MINIO_ROOT_PASSWORD=${WGA_S3_ACCESS_SECRET} 10 | - MINIO_ACCESS_KEY=${WGA_S3_ACCESS_KEY} 11 | - MINIO_SECRET_KEY=${WGA_S3_ACCESS_SECRET} 12 | command: server /data --console-address ":9001" 13 | volumes: 14 | - minio:/data 15 | 16 | createbuckets: 17 | image: minio/mc 18 | depends_on: 19 | - minio 20 | entrypoint: > 21 | /bin/sh -c " 22 | /usr/bin/mc alias set wgaminio http://minio:9000 ${WGA_S3_ACCESS_KEY} ${WGA_S3_ACCESS_SECRET}; 23 | /usr/bin/mc mb wgaminio/${WGA_S3_BUCKET}; 24 | /usr/bin/mc anonymous set public wgaminio/${WGA_S3_BUCKET}; 25 | /usr/bin/mc anonymous set download wgaminio/${WGA_S3_BUCKET}; 26 | exit 0; 27 | " 28 | restart: "no" 29 | 30 | mailpit: 31 | image: axllent/mailpit 32 | ports: 33 | - 8025:8025 34 | - 1025:1025 35 | environment: 36 | - MP_SMTP_AUTH_ACCEPT_ANY=1 37 | - MP_SMTP_AUTH_ALLOW_INSECURE=1 38 | volumes: 39 | - mailpit:/data 40 | 41 | volumes: 42 | minio: 43 | mailpit: 44 | -------------------------------------------------------------------------------- /docs/searchpage.md: -------------------------------------------------------------------------------- 1 | # The Artwork search page 2 | 3 | ```mermaid 4 | sequenceDiagram 5 | actor U as User 6 | participant B as Browser 7 | participant S as Server 8 | U->>B: Open search page 9 | B->>S: Request search page 10 | S->>B: Return search page 11 | B->>U: Display search page 12 | B->>S: Get unfilled search result page 13 | S->>B: Return unfilled search result page 14 | U->>B: Enter search criteria 15 | B->>S: Request search 16 | S->>B: Return search results 17 | B->>U: Display search results 18 | ``` 19 | -------------------------------------------------------------------------------- /errs/form.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | import "errors" 4 | 5 | var ErrMessageRequired = errors.New("message required") 6 | -------------------------------------------------------------------------------- /errs/honeypot.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | import "errors" 4 | 5 | var ErrHoneypotTriggered = errors.New("honeypot triggered") 6 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for wga on 2024-03-26T06:13:52+01:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'wga' 7 | 8 | [build] 9 | [build.args] 10 | GO_VERSION = '1.22.5' 11 | 12 | [env] 13 | PORT = '8080' 14 | 15 | [http_service] 16 | internal_port = 8090 17 | force_https = true 18 | auto_stop_machines = true 19 | auto_start_machines = true 20 | min_machines_running = 0 21 | processes = ['app'] 22 | 23 | [[vm]] 24 | memory = '1gb' 25 | cpu_kind = 'shared' 26 | cpus = 1 27 | -------------------------------------------------------------------------------- /handlers/artist_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGenerateBioSection(t *testing.T) { 8 | // Test case 1: All parameters are provided 9 | result := generateBioSection("b.", 1990, "yes", "New York", "yes") 10 | expected := "b. 1990 New York" 11 | if result != expected { 12 | t.Errorf("Expected %s, but got %s", expected, result) 13 | } 14 | 15 | // Test case 2: Exact year is not applicable 16 | result = generateBioSection("b.", 1990, "no", "New York", "yes") 17 | expected = "b. ~1990 New York" 18 | if result != expected { 19 | t.Errorf("Expected %s, but got %s", expected, result) 20 | } 21 | 22 | // Test case 3: Place of birth is not known 23 | result = generateBioSection("b.", 1990, "yes", "Unknown", "no") 24 | expected = "b. 1990 Unknown?" 25 | if result != expected { 26 | t.Errorf("Expected %s, but got %s", expected, result) 27 | } 28 | } 29 | func TestNormalizedBioExcerpt(t *testing.T) { 30 | d := BioExcerptDTO{ 31 | YearOfBirth: 1990, 32 | ExactYearOfBirth: "yes", 33 | PlaceOfBirth: "New York", 34 | KnownPlaceOfBirth: "yes", 35 | YearOfDeath: 2020, 36 | ExactYearOfDeath: "yes", 37 | PlaceOfDeath: "Los Angeles", 38 | KnownPlaceOfDeath: "yes", 39 | } 40 | 41 | result := normalizedBioExcerpt(d) 42 | expected := "b. 1990 New York, d. 2020 Los Angeles" 43 | if result != expected { 44 | t.Errorf("Expected %s, but got %s", expected, result) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /handlers/artworks/filters.go: -------------------------------------------------------------------------------- 1 | package artworks 2 | 3 | import ( 4 | "github.com/labstack/echo/v5" 5 | "github.com/pocketbase/dbx" 6 | ) 7 | 8 | type filters struct { 9 | Title string 10 | SchoolString string 11 | ArtFormString string 12 | ArtTypeString string 13 | ArtistString string 14 | Page string 15 | } 16 | 17 | // AnyFilterActive checks if any filter is active. 18 | // It returns true if any of the filter fields (Title, SchoolString, ArtFormString, ArtTypeString, ArtistString) is not empty. 19 | func (f *filters) AnyFilterActive() bool { 20 | return f.Title != "" || f.SchoolString != "" || f.ArtFormString != "" || f.ArtTypeString != "" || f.ArtistString != "" 21 | } 22 | 23 | // FingerPrint returns a unique fingerprint string based on the filter values. 24 | // The fingerprint is generated by concatenating the title, school, art form, art type, and artist strings. 25 | func (f *filters) FingerPrint() string { 26 | return f.Title + ":" + f.SchoolString + ":" + f.ArtFormString + ":" + f.ArtTypeString + ":" + f.ArtistString + ":" + f.Page 27 | } 28 | 29 | // BuildFilter builds a filter string and parameters based on the values of the filters struct. 30 | // The filter string is used to filter artworks based on various criteria such as title, school, art form, art type, and artist. 31 | // The parameters map contains the values to be substituted in the filter string. 32 | // The filter string and parameters are returned as a string and dbx.Params respectively. 33 | func (f *filters) BuildFilter() (string, dbx.Params) { 34 | filterString := "published = true && author:length > 0" 35 | params := dbx.Params{} 36 | 37 | if f.Title != "" { 38 | filterString = filterString + " && title ~ {:title}" 39 | params["title"] = f.Title 40 | } 41 | 42 | if f.SchoolString != "" { 43 | filterString = filterString + " && school.slug = {:art_school}" 44 | params["art_school"] = f.SchoolString 45 | } 46 | 47 | if f.ArtFormString != "" { 48 | filterString = filterString + " && form.slug = {:art_form}" 49 | params["art_form"] = f.ArtFormString 50 | } 51 | 52 | if f.ArtTypeString != "" { 53 | filterString = filterString + " && type.slug = {:art_type}" 54 | params["art_type"] = f.ArtTypeString 55 | } 56 | 57 | if f.ArtistString != "" { 58 | filterString = filterString + " && author.name ~ {:artist}" 59 | params["artist"] = f.ArtistString 60 | } 61 | 62 | return filterString, params 63 | } 64 | 65 | // BuildFilterString builds a filter string based on the values of the filters struct. 66 | // It concatenates the filter parameters with their corresponding values and returns the resulting filter string. 67 | func (f *filters) BuildFilterString() string { 68 | filterString := "" 69 | 70 | if f.Title != "" { 71 | filterString = filterString + "&title=" + f.Title 72 | } 73 | 74 | if f.SchoolString != "" { 75 | filterString = filterString + "&art_school=" + f.SchoolString 76 | } 77 | 78 | if f.ArtFormString != "" { 79 | filterString = filterString + "&art_form=" + f.ArtFormString 80 | } 81 | 82 | if f.ArtTypeString != "" { 83 | filterString = filterString + "&art_type=" + f.ArtTypeString 84 | } 85 | 86 | if f.ArtistString != "" { 87 | filterString = filterString + "&artist=" + f.ArtistString 88 | } 89 | 90 | if f.Page != "" { 91 | filterString = filterString + "&page=" + f.Page 92 | } 93 | 94 | return filterString 95 | } 96 | 97 | func buildFilters(c echo.Context) *filters { 98 | f := &filters{ 99 | Title: c.QueryParamDefault("title", ""), 100 | SchoolString: c.QueryParamDefault("art_school", ""), 101 | ArtFormString: c.QueryParamDefault("art_form", ""), 102 | ArtTypeString: c.QueryParamDefault("art_type", ""), 103 | ArtistString: c.QueryParamDefault("artist", ""), 104 | Page: c.QueryParamDefault("page", ""), 105 | } 106 | 107 | return f 108 | } 109 | -------------------------------------------------------------------------------- /handlers/artworks/getters.go: -------------------------------------------------------------------------------- 1 | package artworks 2 | 3 | import ( 4 | "github.com/blackfyre/wga/models" 5 | "github.com/pocketbase/pocketbase" 6 | ) 7 | 8 | // getArtTypesOptions returns a map of art type slugs and their corresponding names. 9 | // It retrieves the art types from the database using the provided PocketBase app instance. 10 | func getArtTypesOptions(app *pocketbase.PocketBase) (map[string]string, error) { 11 | options := map[string]string{ 12 | "": "Any", 13 | } 14 | c, err := models.GetArtTypes(app.Dao()) 15 | 16 | if err != nil { 17 | return options, err 18 | } 19 | 20 | for _, v := range c { 21 | options[v.Slug] = v.Name 22 | } 23 | 24 | return options, nil 25 | } 26 | 27 | // getArtFormOptions returns a map of art form slugs to their corresponding names. 28 | // It retrieves the art forms from the database using the provided PocketBase app instance. 29 | func getArtFormOptions(app *pocketbase.PocketBase) (map[string]string, error) { 30 | options := map[string]string{ 31 | "": "Any", 32 | } 33 | c, err := models.GetArtForms(app.Dao()) 34 | 35 | if err != nil { 36 | return options, err 37 | } 38 | 39 | for _, v := range c { 40 | options[v.Slug] = v.Name 41 | } 42 | 43 | return options, nil 44 | } 45 | 46 | // getArtSchoolOptions returns a map of art school options where the key is the slug and the value is the name. 47 | func getArtSchoolOptions(app *pocketbase.PocketBase) (map[string]string, error) { 48 | options := map[string]string{ 49 | "": "Any", 50 | } 51 | c, err := models.GetSchools(app.Dao()) 52 | 53 | if err != nil { 54 | return options, err 55 | } 56 | 57 | for _, v := range c { 58 | options[v.Slug] = v.Name 59 | } 60 | 61 | return options, nil 62 | } 63 | 64 | func getArtistNameList(app *pocketbase.PocketBase) ([]string, error) { 65 | var names []string 66 | c, err := app.Dao().FindRecordsByFilter( 67 | "artists", 68 | "published = true", 69 | "+name", 70 | 0, 71 | 0, 72 | ) 73 | 74 | if err != nil { 75 | return names, err 76 | } 77 | 78 | for _, v := range c { 79 | names = append(names, v.GetString("name")) 80 | } 81 | 82 | return names, nil 83 | } 84 | -------------------------------------------------------------------------------- /handlers/contributors.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/blackfyre/wga/assets/templ/pages" 12 | tmplUtils "github.com/blackfyre/wga/assets/templ/utils" 13 | "github.com/labstack/echo/v5" 14 | "github.com/pocketbase/pocketbase" 15 | "github.com/pocketbase/pocketbase/apis" 16 | "github.com/pocketbase/pocketbase/core" 17 | ) 18 | 19 | func getContributorsFromGithub(app *pocketbase.PocketBase) ([]pages.GithubContributor, error) { 20 | 21 | ghContribCacheKey := "gh_contributors" 22 | 23 | if app.Store().Has(ghContribCacheKey) { 24 | return app.Store().Get(ghContribCacheKey).([]pages.GithubContributor), nil 25 | } 26 | 27 | client := &http.Client{ 28 | Timeout: 10 * time.Second, 29 | } 30 | 31 | req, err := http.NewRequest("GET", "https://api.github.com/repos/blackfyre/wga/contributors", nil) 32 | 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | req.Header.Set("Accept", "application/vnd.github.v3+json") 38 | req.Header.Set("User-Agent", "blackfyre/wga") 39 | 40 | resp, err := client.Do(req) 41 | 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | defer func(Body io.ReadCloser) { 47 | err := Body.Close() 48 | if err != nil { 49 | app.Logger().Error("Error closing response body", "error", err) 50 | } 51 | }(resp.Body) 52 | 53 | var contributors []pages.GithubContributor 54 | 55 | err = json.NewDecoder(resp.Body).Decode(&contributors) 56 | 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | // write to file 62 | f, err := os.Create("contributors.json") 63 | 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | defer func(f *os.File) { 69 | err := f.Close() 70 | if err != nil { 71 | app.Logger().Error("Error closing file", "error", err) 72 | } 73 | }(f) 74 | 75 | err = json.NewEncoder(f).Encode(contributors) 76 | 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | app.Store().Set(ghContribCacheKey, contributors) 82 | 83 | return contributors, nil 84 | } 85 | 86 | func readStoredContributors(app *pocketbase.PocketBase) ([]pages.GithubContributor, error) { 87 | f, err := os.Open("contributors.json") 88 | 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | defer func(f *os.File) { 94 | err := f.Close() 95 | if err != nil { 96 | app.Logger().Error("Error closing file", "error", err) 97 | } 98 | }(f) 99 | 100 | var contributors []pages.GithubContributor 101 | 102 | err = json.NewDecoder(f).Decode(&contributors) 103 | 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | return contributors, nil 109 | } 110 | 111 | func registerContributors(app *pocketbase.PocketBase) { 112 | app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 113 | e.Router.GET("/contributors", func(c echo.Context) error { 114 | 115 | cacheKey := "contributors" 116 | fullUrl := c.Scheme() + "://" + c.Request().Host + c.Request().URL.String() 117 | 118 | contributors, err := getContributorsFromGithub(app) 119 | 120 | if err != nil { 121 | 122 | app.Logger().Error("Error getting contributors from Github", "cacheKey", cacheKey, "error", err) 123 | 124 | contributors, err = readStoredContributors(app) 125 | 126 | if err != nil { 127 | app.Logger().Error("Error reading stored contributors", "cacheKey", cacheKey, "error", err) 128 | return apis.NewApiError(500, err.Error(), err) 129 | } 130 | } 131 | 132 | content := pages.ContributorsPageDTO{ 133 | Contributors: contributors, 134 | } 135 | 136 | ctx := tmplUtils.DecorateContext(context.Background(), tmplUtils.TitleKey, "Contributors") 137 | ctx = tmplUtils.DecorateContext(ctx, tmplUtils.DescriptionKey, "The people who have contributed to the Web Gallery of Art.") 138 | ctx = tmplUtils.DecorateContext(ctx, tmplUtils.CanonicalUrlKey, fullUrl) 139 | 140 | c.Response().Header().Set("HX-Push-Url", fullUrl) 141 | err = pages.ContributorsPage(content).Render(ctx, c.Response().Writer) 142 | 143 | if err != nil { 144 | app.Logger().Error("Error rendering artwork page", "error", err.Error()) 145 | return c.String(http.StatusInternalServerError, "failed to render response template") 146 | } 147 | 148 | return nil 149 | 150 | }) 151 | 152 | return nil 153 | }) 154 | } 155 | -------------------------------------------------------------------------------- /handlers/guestbook.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/blackfyre/wga/handlers/guestbook" 5 | "github.com/blackfyre/wga/utils" 6 | 7 | "github.com/labstack/echo/v5" 8 | "github.com/pocketbase/pocketbase" 9 | "github.com/pocketbase/pocketbase/core" 10 | ) 11 | 12 | // registerGuestbookHandlers registers the handlers for the guestbook routes. 13 | // It takes an instance of pocketbase.PocketBase as input and adds the necessary 14 | // route handlers to the app's router. The handlers include GET and POST methods 15 | // for displaying and adding messages to the guestbook. 16 | func registerGuestbookHandlers(app *pocketbase.PocketBase) { 17 | 18 | app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 19 | 20 | e.Router.GET("/guestbook", func(c echo.Context) error { 21 | return guestbook.EntriesHandler(app, c) 22 | }) 23 | 24 | e.Router.GET("/guestbook/add", func(c echo.Context) error { 25 | return guestbook.StoreEntryViewHandler(app, c) 26 | }, utils.IsHtmxRequestMiddleware) 27 | 28 | e.Router.POST("/guestbook/add", func(c echo.Context) error { 29 | return guestbook.StoreEntryHandler(app, c) 30 | }, utils.IsHtmxRequestMiddleware) 31 | 32 | return nil 33 | 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /handlers/inspire/main.go: -------------------------------------------------------------------------------- 1 | package inspire 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/blackfyre/wga/assets/templ/dto" 7 | "github.com/blackfyre/wga/assets/templ/pages" 8 | "github.com/blackfyre/wga/models" 9 | "github.com/blackfyre/wga/utils" 10 | "github.com/blackfyre/wga/utils/url" 11 | "github.com/labstack/echo/v5" 12 | "github.com/pocketbase/pocketbase" 13 | "github.com/pocketbase/pocketbase/core" 14 | 15 | tmplUtils "github.com/blackfyre/wga/assets/templ/utils" 16 | ) 17 | 18 | func inspirationHandler(app *pocketbase.PocketBase, c echo.Context) error { 19 | 20 | items, err := models.GetRandomArtworks(app.Dao(), 20) 21 | 22 | if err != nil { 23 | app.Logger().Error("Error getting random artworks", "error", err.Error()) 24 | return utils.ServerFaultError(c) 25 | } 26 | 27 | content := dto.ImageGrid{} 28 | 29 | for _, item := range items { 30 | 31 | artworkId := item.GetId() 32 | 33 | artist, err := models.GetArtistById(app.Dao(), item.Author) 34 | 35 | if err != nil { 36 | app.Logger().Error("Error getting artist for artwork %s: %v", item.GetId(), err) 37 | return utils.ServerFaultError(c) 38 | } 39 | 40 | content = append(content, dto.Image{ 41 | Url: url.GenerateArtworkUrl(url.ArtworkUrlDTO{ 42 | ArtistId: artist.Id, 43 | ArtistName: artist.Name, 44 | ArtworkTitle: item.Author, 45 | ArtworkId: item.Id, 46 | }), 47 | Image: url.GenerateFileUrl("artworks", artworkId, item.Image, ""), 48 | Thumb: url.GenerateThumbUrl("artworks", artworkId, item.Image, "320x240", ""), 49 | Comment: item.Comment, 50 | Title: item.Title, 51 | Technique: item.Technique, 52 | Id: artworkId, 53 | Artist: dto.Artist{ 54 | Id: artist.Id, 55 | Name: artist.Name, 56 | Url: url.GenerateArtistUrl(url.ArtistUrlDTO{ 57 | ArtistId: artist.Id, 58 | ArtistName: artist.Name, 59 | }), 60 | Profession: artist.Profession, 61 | }, 62 | }) 63 | } 64 | 65 | ctx := tmplUtils.DecorateContext(context.Background(), tmplUtils.TitleKey, "Inspiration") 66 | 67 | c.Response().Header().Set("HX-Push-Url", "/inspire") 68 | err = pages.InspirePage(content).Render(ctx, c.Response().Writer) 69 | 70 | if err != nil { 71 | return utils.ServerFaultError(c) 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func RegisterHandlers(app *pocketbase.PocketBase) { 78 | app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 79 | e.Router.GET("/inspire", func(c echo.Context) error { 80 | return inspirationHandler(app, c) 81 | }) 82 | return nil 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /handlers/main.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/blackfyre/wga/handlers/artworks" 5 | "github.com/blackfyre/wga/handlers/feedback" 6 | "github.com/blackfyre/wga/handlers/inspire" 7 | "github.com/blackfyre/wga/handlers/postcards" 8 | "github.com/microcosm-cc/bluemonday" 9 | "github.com/pocketbase/pocketbase" 10 | ) 11 | 12 | // RegisterHandlers registers all the handlers for the application. 13 | // It takes a pointer to a PocketBase instance and initializes the cache. 14 | // The cache is used to store frequently accessed data for faster access. 15 | // The cache is automatically cleaned up every 30 minutes. 16 | func RegisterHandlers(app *pocketbase.PocketBase) { 17 | 18 | p := bluemonday.NewPolicy() 19 | 20 | feedback.RegisterHandlers(app) 21 | registerMusicHandlers(app) 22 | registerGuestbookHandlers(app) 23 | registerArtist(app) 24 | registerArtists(app) 25 | postcards.RegisterPostcardHandlers(app, p) 26 | registerContributors(app) 27 | registerStatic(app) 28 | artworks.RegisterArtworksHandlers(app) 29 | inspire.RegisterHandlers(app) 30 | registerHome(app) 31 | } 32 | -------------------------------------------------------------------------------- /handlers/postcards/main.go: -------------------------------------------------------------------------------- 1 | package postcards 2 | 3 | import ( 4 | "github.com/blackfyre/wga/utils" 5 | "github.com/labstack/echo/v5" 6 | "github.com/microcosm-cc/bluemonday" 7 | "github.com/pocketbase/pocketbase" 8 | "github.com/pocketbase/pocketbase/core" 9 | ) 10 | 11 | func RegisterPostcardHandlers(app *pocketbase.PocketBase, p *bluemonday.Policy) { 12 | app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 13 | e.Router.GET("postcard/send", func(c echo.Context) error { 14 | return sendPostcard(app, c) 15 | }, utils.IsHtmxRequestMiddleware) 16 | 17 | e.Router.GET("postcards", func(c echo.Context) error { 18 | 19 | return viewPostcard(app, c) 20 | }) 21 | 22 | e.Router.POST("postcards", func(c echo.Context) error { 23 | return savePostcard(app, c, p) 24 | }, utils.IsHtmxRequestMiddleware) 25 | return nil 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /handlers/postcards/save.go: -------------------------------------------------------------------------------- 1 | package postcards 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/blackfyre/wga/utils" 8 | "github.com/labstack/echo/v5" 9 | "github.com/microcosm-cc/bluemonday" 10 | "github.com/pocketbase/pocketbase" 11 | "github.com/pocketbase/pocketbase/forms" 12 | "github.com/pocketbase/pocketbase/models" 13 | ) 14 | 15 | func savePostcard(app *pocketbase.PocketBase, c echo.Context, p *bluemonday.Policy) error { 16 | postData := struct { 17 | SenderName string `json:"sender_name" form:"sender_name" query:"sender_name" validate:"required"` 18 | SenderEmail string `json:"sender_email" form:"sender_email" query:"sender_email" validate:"required,email"` 19 | Recipients []string `json:"recipients" form:"recipients[]" query:"recipients" validate:"required"` 20 | Message string `json:"message" form:"message" query:"message" validate:"required"` 21 | ImageId string `json:"image_id" form:"image_id" query:"image_id" validate:"required"` 22 | NotificationRequired bool `json:"notification_required" form:"notify_sender" query:"notification_required"` 23 | RecaptchaToken string `json:"recaptcha_token" form:"g-recaptcha-response" query:"recaptcha_token" validate:"required"` 24 | HoneyPotName string `json:"honey_pot_name" form:"name" query:"honey_pot_name"` 25 | HoneyPotEmail string `json:"honey_pot_email" form:"email" query:"honey_pot_email"` 26 | }{} 27 | 28 | if err := c.Bind(&postData); err != nil { 29 | app.Logger().Error("Failed to parse form", "error", err.Error()) 30 | utils.SendToastMessage("Failed to parse form", "error", true, c, "") 31 | return utils.ServerFaultError(c) 32 | } 33 | 34 | if postData.HoneyPotEmail != "" || postData.HoneyPotName != "" { 35 | // this is probably a bot 36 | app.Logger().Warn("Honey pot triggered", "data", fmt.Sprintf("%+v", postData), "ip", c.RealIP()) 37 | return utils.ServerFaultError(c) 38 | } 39 | 40 | collection, err := app.Dao().FindCollectionByNameOrId("postcards") 41 | if err != nil { 42 | app.Logger().Error("Failed to find postcard collection", "error", err.Error()) 43 | return utils.NotFoundError(c) 44 | } 45 | 46 | record := models.NewRecord(collection) 47 | 48 | form := forms.NewRecordUpsert(app, record) 49 | 50 | err = form.LoadData(map[string]any{ 51 | "status": "queued", 52 | "sender_name": postData.SenderName, 53 | "sender_email": postData.SenderEmail, 54 | "recipients": strings.Join(postData.Recipients, ","), 55 | "message": p.Sanitize(postData.Message), 56 | "image_id": postData.ImageId, 57 | "notify_sender": postData.NotificationRequired, 58 | }) 59 | 60 | if err != nil { 61 | app.Logger().Error("Failed to process postcard form", "error", err.Error()) 62 | utils.SendToastMessage("Failed to process postcard form", "error", true, c, "") 63 | return utils.ServerFaultError(c) 64 | } 65 | 66 | if err := form.Submit(); err != nil { 67 | 68 | return renderForm(postData.ImageId, app, c) 69 | } 70 | 71 | utils.SendToastMessage("Thank you! Your postcard has been queued for sending!", "success", true, c, "") 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /handlers/postcards/send.go: -------------------------------------------------------------------------------- 1 | package postcards 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/blackfyre/wga/assets/templ/components" 8 | "github.com/blackfyre/wga/utils" 9 | "github.com/blackfyre/wga/utils/url" 10 | "github.com/labstack/echo/v5" 11 | "github.com/pocketbase/pocketbase" 12 | ) 13 | 14 | func sendPostcard(app *pocketbase.PocketBase, c echo.Context) error { 15 | 16 | artworkId, err := url.GetRequiredQueryParam(c, "awid") 17 | 18 | if err != nil { 19 | app.Logger().Error("Failed to get required query param", "error", err.Error()) 20 | return utils.BadRequestError(c) 21 | } 22 | 23 | return renderForm(artworkId, app, c) 24 | } 25 | 26 | func renderForm(artworkId string, app *pocketbase.PocketBase, c echo.Context) error { 27 | ctx := context.Background() 28 | 29 | r, err := app.Dao().FindRecordById("artworks", artworkId) 30 | 31 | if err != nil { 32 | app.Logger().Error("Failed to find artwork "+artworkId, "error", err.Error()) 33 | return utils.NotFoundError(c) 34 | } 35 | 36 | err = components.PostcardEditor(components.PostcardEditorDTO{ 37 | Image: url.GenerateFileUrl("artworks", artworkId, r.GetString("image"), ""), 38 | ImageId: artworkId, 39 | Title: r.GetString("title"), 40 | Comment: r.GetString("comment"), 41 | Technique: r.GetString("technique"), 42 | }).Render(ctx, c.Response().Writer) 43 | 44 | if err != nil { 45 | app.Logger().Error(fmt.Sprintf("Failed to render the postcard editor with image_id %s", artworkId), "error", err.Error()) 46 | return utils.ServerFaultError(c) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /handlers/postcards/view.go: -------------------------------------------------------------------------------- 1 | package postcards 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/blackfyre/wga/assets/templ/pages" 8 | tmplUtils "github.com/blackfyre/wga/assets/templ/utils" 9 | "github.com/blackfyre/wga/utils" 10 | "github.com/blackfyre/wga/utils/url" 11 | "github.com/labstack/echo/v5" 12 | "github.com/pocketbase/pocketbase" 13 | ) 14 | 15 | func viewPostcard(app *pocketbase.PocketBase, c echo.Context) error { 16 | postCardId := c.QueryParamDefault("p", "nope") 17 | 18 | if postCardId == "nope" { 19 | app.Logger().Error(fmt.Sprintf("Invalid postcard id: %s", postCardId)) 20 | return utils.NotFoundError(c) 21 | } 22 | 23 | r, err := app.Dao().FindRecordById("Postcards", postCardId) 24 | 25 | if err != nil { 26 | app.Logger().Error("Failed to find postcard", "id", postCardId, "error", err.Error()) 27 | return utils.NotFoundError(c) 28 | } 29 | 30 | if errs := app.Dao().ExpandRecord(r, []string{"image_id"}, nil); len(errs) > 0 { 31 | app.Logger().Error("Failed to expand record", "id", postCardId, "errors", errs) 32 | return utils.ServerFaultError(c) 33 | } 34 | 35 | aw := r.ExpandedOne("image_id") 36 | 37 | content := pages.PostcardView{ 38 | SenderName: r.GetString("sender_name"), 39 | Message: r.GetString("message"), 40 | Image: url.GenerateFileUrl("artworks", aw.GetString("id"), aw.GetString("image"), ""), 41 | Title: aw.GetString("title"), 42 | Comment: aw.GetString("comment"), 43 | Technique: aw.GetString("technique"), 44 | } 45 | 46 | ctx := tmplUtils.DecorateContext(context.Background(), tmplUtils.TitleKey, "Postcard") 47 | 48 | //c.Response().Header().Set("HX-Push-Url", fullUrl) 49 | err = pages.PostcardPage(content).Render(ctx, c.Response().Writer) 50 | 51 | if err != nil { 52 | app.Logger().Error("Error rendering artwork page", "error", err.Error()) 53 | return utils.ServerFaultError(c) 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /handlers/static.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "io/fs" 6 | "os" 7 | 8 | "github.com/blackfyre/wga/assets" 9 | "github.com/blackfyre/wga/assets/templ/error_pages" 10 | "github.com/blackfyre/wga/assets/templ/pages" 11 | tmplUtils "github.com/blackfyre/wga/assets/templ/utils" 12 | "github.com/blackfyre/wga/models" 13 | "github.com/blackfyre/wga/utils" 14 | "github.com/labstack/echo/v5" 15 | "github.com/pocketbase/pocketbase" 16 | "github.com/pocketbase/pocketbase/apis" 17 | "github.com/pocketbase/pocketbase/core" 18 | ) 19 | 20 | func getFilePublicSystem() fs.FS { 21 | fsys, err := fs.Sub(assets.PublicFiles, "public") 22 | 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | return fsys 28 | } 29 | 30 | // registerStatic registers the static routes for the application. 31 | // It adds a middleware to serve static assets and a handler to serve static pages. 32 | // The static pages are retrieved from the database based on the slug parameter in the URL. 33 | // If the request is an Htmx request, only the content block is rendered, otherwise the entire page is rendered. 34 | // The function returns an error if there was a problem registering the routes. 35 | func registerStatic(app *pocketbase.PocketBase) { 36 | app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 37 | // Assets 38 | e.Router.GET("/assets/*", apis.StaticDirectoryHandler(getFilePublicSystem(), false)) 39 | 40 | // Sitemap 41 | e.Router.GET("/sitemap/*", apis.StaticDirectoryHandler(os.DirFS("./wga_sitemap"), false)) 42 | 43 | // "Static" pages 44 | e.Router.GET("/pages/:slug", func(c echo.Context) error { 45 | 46 | slug := c.PathParam("slug") 47 | fullUrl := c.Scheme() + "://" + c.Request().Host + c.Request().URL.String() 48 | 49 | page, err := models.FindStaticPageBySlug(app.Dao(), slug) 50 | 51 | if err != nil { 52 | app.Logger().Error("Error retrieving static page", "page", slug, "error", err) 53 | 54 | return utils.NotFoundError(c) 55 | } 56 | 57 | content := pages.StaticPageDTO{ 58 | Title: page.Title, 59 | Content: page.Content, 60 | Url: "/pages/" + page.Slug, 61 | } 62 | 63 | ctx := tmplUtils.DecorateContext(context.Background(), tmplUtils.TitleKey, page.Title) 64 | ctx = tmplUtils.DecorateContext(ctx, tmplUtils.DescriptionKey, page.Content) 65 | ctx = tmplUtils.DecorateContext(ctx, tmplUtils.CanonicalUrlKey, fullUrl) 66 | 67 | c.Response().Header().Set("HX-Push-Url", fullUrl) 68 | return pages.StaticPage(content).Render(ctx, c.Response().Writer) 69 | 70 | }) 71 | 72 | e.Router.GET("/error_404", func(c echo.Context) error { 73 | c.Response().Header().Set("HX-Push-Url", "/error_404") 74 | return error_pages.NotFoundPage().Render(context.Background(), c.Response().Writer) 75 | }) 76 | 77 | return nil 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /handlers/utils.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/labstack/echo/v5" 6 | "github.com/pocketbase/pocketbase/models" 7 | ) 8 | 9 | func normalizedBirthDeathActivity(record *models.Record) string { 10 | Start := record.GetInt("year_of_birth") 11 | End := record.GetInt("year_of_death") 12 | 13 | return fmt.Sprintf("%d-%d", Start, End) 14 | } 15 | 16 | func generateArtistSlug(artist *models.Record) string { 17 | if artist == nil { 18 | return "" 19 | } 20 | return artist.GetString("slug") + "-" + artist.GetString("id") 21 | } 22 | 23 | func generateCurrentPageUrl(c echo.Context) string { 24 | if c == nil || c.Request() == nil { 25 | return "" 26 | } 27 | return c.Scheme() + "://" + c.Request().Host + c.Request().URL.String() 28 | } 29 | -------------------------------------------------------------------------------- /hooks/main.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import "github.com/pocketbase/pocketbase" 4 | 5 | func RegisterHooks(app *pocketbase.PocketBase) { 6 | registerStringsUpdate(app) 7 | } 8 | -------------------------------------------------------------------------------- /hooks/strings.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase" 5 | "github.com/pocketbase/pocketbase/core" 6 | ) 7 | 8 | func registerStringsUpdate(app *pocketbase.PocketBase) { 9 | app.OnModelBeforeUpdate("strings").Add(func(e *core.ModelEvent) error { 10 | 11 | // record, _ := e.Model.(*models.Record) 12 | // content := record.Get("content").(string) 13 | 14 | // content = content + "

    !!!

    " 15 | // record.Set("content", content) 16 | 17 | return nil 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /mailpit.log: -------------------------------------------------------------------------------- 1 | time="2024/07/31 06:28:33" level=debug msg="[db] using temporary database: /tmp/nix-shell.g5ysBe/mailpit-1722400113006046905.db" 2 | time="2024/07/31 06:28:33" level=debug msg="[db] opening database /tmp/nix-shell.g5ysBe/mailpit-1722400113006046905.db" 3 | time="2024/07/31 06:28:33" level=info msg="[smtpd] starting on [::]:1025 (no encryption)" 4 | time="2024/07/31 06:28:33" level=error msg="listen tcp 0.0.0.0:1025: bind: address already in use" 5 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/blackfyre/wga/crontab" 8 | "github.com/blackfyre/wga/handlers" 9 | "github.com/blackfyre/wga/hooks" 10 | _ "github.com/blackfyre/wga/migrations" 11 | 12 | "github.com/blackfyre/wga/utils" 13 | "github.com/blackfyre/wga/utils/seed" 14 | "github.com/blackfyre/wga/utils/sitemap" 15 | "github.com/joho/godotenv" 16 | "github.com/pocketbase/pocketbase" 17 | "github.com/pocketbase/pocketbase/plugins/migratecmd" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | func main() { 22 | 23 | _ = godotenv.Load() 24 | 25 | app := pocketbase.NewWithConfig(pocketbase.Config{ 26 | DefaultDataDir: "./wga_data", 27 | }) 28 | 29 | // app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 30 | // e.Router.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ 31 | // TokenLookup: "header:X-XSRF-TOKEN", 32 | // })) 33 | 34 | // return nil 35 | // }) 36 | 37 | handlers.RegisterHandlers(app) 38 | hooks.RegisterHooks(app) 39 | crontab.RegisterCronJobs(app) 40 | 41 | migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ 42 | // enable auto creation of migration files when making collection changes in the Admin UI 43 | // (the isGoRun check is to enable it only during development) 44 | Automigrate: false, 45 | }) 46 | 47 | app.RootCmd.AddCommand(&cobra.Command{ 48 | Use: "generate-sitemap", 49 | Short: "Generate sitemap", 50 | Run: func(cmd *cobra.Command, args []string) { 51 | sitemap.GenerateSiteMap(app) 52 | }, 53 | }) 54 | 55 | app.RootCmd.AddCommand(&cobra.Command{ 56 | Use: "generate-music-urls", 57 | Short: "Generate music urls", 58 | Run: func(cmd *cobra.Command, args []string) { 59 | utils.ParseMusicListToUrls("./assets/reference/musics.json") 60 | }, 61 | }) 62 | 63 | if os.Getenv("WGA_ENV") == "development" { 64 | app.RootCmd.AddCommand(&cobra.Command{ 65 | Use: "seed:images", 66 | Short: "Seed images to the specified S3 bucket", 67 | Run: func(cmd *cobra.Command, args []string) { 68 | err := seed.SeedImages(app) 69 | 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | 74 | log.Println("Done seeding images") 75 | 76 | }, 77 | }) 78 | } 79 | 80 | if err := app.Start(); err != nil { 81 | log.Fatal(err) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /migrations/1687801090_initial_settings.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/joho/godotenv" 7 | "github.com/pocketbase/dbx" 8 | "github.com/pocketbase/pocketbase/daos" 9 | m "github.com/pocketbase/pocketbase/migrations" 10 | ) 11 | 12 | func init() { 13 | 14 | _ = godotenv.Load() 15 | 16 | m.Register(func(db dbx.Builder) error { 17 | dao := daos.New(db) 18 | 19 | settings, _ := dao.FindSettings() 20 | settings.Meta.AppName = "Web Gallery of Art" 21 | settings.Logs.MaxDays = 30 22 | settings.Meta.SenderName = "Web Gallery of Art" 23 | settings.Meta.SenderAddress = "info@wga.hu" 24 | settings.S3.Enabled = true 25 | settings.S3.Endpoint = os.Getenv("WGA_S3_ENDPOINT") 26 | settings.S3.AccessKey = os.Getenv("WGA_S3_ACCESS_KEY") 27 | settings.S3.Bucket = os.Getenv("WGA_S3_BUCKET") 28 | settings.S3.Secret = os.Getenv("WGA_S3_ACCESS_SECRET") 29 | settings.S3.Region = os.Getenv("WGA_S3_REGION") 30 | settings.S3.ForcePathStyle = true 31 | 32 | return dao.SaveSettings(settings) 33 | }, nil) 34 | } 35 | -------------------------------------------------------------------------------- /migrations/1695117058_strings_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/blackfyre/wga/assets" 7 | "github.com/pocketbase/dbx" 8 | "github.com/pocketbase/pocketbase/daos" 9 | m "github.com/pocketbase/pocketbase/migrations" 10 | "github.com/pocketbase/pocketbase/models" 11 | "github.com/pocketbase/pocketbase/models/schema" 12 | ) 13 | 14 | type PublicString struct { 15 | Name string `json:"name"` 16 | Content string `json:"content"` 17 | } 18 | 19 | func init() { 20 | m.Register(func(db dbx.Builder) error { 21 | dao := daos.New(db) 22 | 23 | collection := &models.Collection{} 24 | 25 | collection.Name = "Strings" 26 | collection.Type = models.CollectionTypeBase 27 | collection.System = false 28 | collection.Id = "strings" 29 | collection.MarkAsNew() 30 | collection.Schema = schema.NewSchema( 31 | &schema.SchemaField{ 32 | Id: "strings_name", 33 | Name: "name", 34 | Type: schema.FieldTypeText, 35 | Options: &schema.TextOptions{}, 36 | Presentable: true, 37 | }, 38 | &schema.SchemaField{ 39 | Id: "strings_content", 40 | Name: "content", 41 | Type: schema.FieldTypeEditor, 42 | Options: &schema.EditorOptions{}, 43 | }, 44 | ) 45 | 46 | err := dao.SaveCollection(collection) 47 | 48 | if err != nil { 49 | return err 50 | } 51 | 52 | data, err := assets.InternalFiles.ReadFile("reference/strings.json") 53 | 54 | if err != nil { 55 | return err 56 | } 57 | 58 | var c []PublicString 59 | 60 | err = json.Unmarshal(data, &c) 61 | 62 | if err != nil { 63 | return err 64 | } 65 | 66 | for _, i := range c { 67 | q := db.Insert("strings", dbx.Params{ 68 | "name": i.Name, 69 | "content": i.Content, 70 | }) 71 | 72 | _, err = q.Execute() 73 | 74 | if err != nil { 75 | return err 76 | } 77 | 78 | } 79 | 80 | return nil 81 | 82 | // add up queries... 83 | // columns := map[string]string{ 84 | // "id": "text", 85 | // "created": "text", 86 | // "updated": "text", 87 | // "field_name": "text", 88 | // "content": "text", 89 | // } 90 | 91 | // q := db.CreateTable("strings", columns) 92 | // _, err := q.Execute() 93 | 94 | // return err 95 | }, func(db dbx.Builder) error { 96 | 97 | q := db.DropTable("strings") 98 | _, err := q.Execute() 99 | 100 | return err 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /migrations/1695699035_add_schools_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/blackfyre/wga/assets" 7 | "github.com/blackfyre/wga/utils" 8 | "github.com/pocketbase/dbx" 9 | "github.com/pocketbase/pocketbase/daos" 10 | m "github.com/pocketbase/pocketbase/migrations" 11 | "github.com/pocketbase/pocketbase/models" 12 | "github.com/pocketbase/pocketbase/models/schema" 13 | ) 14 | 15 | type School struct { 16 | Id string `json:"id"` 17 | Name string `json:"name"` 18 | } 19 | 20 | func init() { 21 | m.Register(func(db dbx.Builder) error { 22 | dao := daos.New(db) 23 | 24 | collection := &models.Collection{} 25 | 26 | collection.Name = "Schools" 27 | collection.Id = "schools" 28 | collection.Type = models.CollectionTypeBase 29 | collection.System = false 30 | collection.MarkAsNew() 31 | collection.Schema = schema.NewSchema( 32 | &schema.SchemaField{ 33 | Id: "schools_name", 34 | Name: "name", 35 | Type: schema.FieldTypeText, 36 | Options: &schema.TextOptions{}, 37 | Presentable: true, 38 | }, 39 | &schema.SchemaField{ 40 | Id: "schools_slug", 41 | Name: "slug", 42 | Type: schema.FieldTypeText, 43 | Options: &schema.TextOptions{}, 44 | }, 45 | ) 46 | 47 | err := dao.SaveCollection(collection) 48 | 49 | if err != nil { 50 | return err 51 | } 52 | 53 | data, err := assets.InternalFiles.ReadFile("reference/schools.json") 54 | 55 | if err != nil { 56 | return err 57 | } 58 | 59 | var c []School 60 | 61 | err = json.Unmarshal(data, &c) 62 | 63 | if err != nil { 64 | return err 65 | } 66 | 67 | for _, i := range c { 68 | q := db.Insert("schools", dbx.Params{ 69 | "id": i.Id, 70 | "name": i.Name, 71 | "slug": utils.Slugify(i.Name), 72 | }) 73 | 74 | _, err = q.Execute() 75 | 76 | if err != nil { 77 | return err 78 | } 79 | 80 | } 81 | 82 | return nil 83 | }, func(db dbx.Builder) error { 84 | // add down queries... 85 | 86 | return nil 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /migrations/1695699092_glossary_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/blackfyre/wga/assets" 7 | "github.com/pocketbase/dbx" 8 | "github.com/pocketbase/pocketbase/daos" 9 | m "github.com/pocketbase/pocketbase/migrations" 10 | "github.com/pocketbase/pocketbase/models" 11 | "github.com/pocketbase/pocketbase/models/schema" 12 | ) 13 | 14 | type Glossary struct { 15 | Id string `db:"id" json:"id"` 16 | Expression string `db:"expression" json:"expression"` 17 | Definition string `db:"definition" json:"definition"` 18 | } 19 | 20 | func init() { 21 | m.Register(func(db dbx.Builder) error { 22 | dao := daos.New(db) 23 | 24 | collection := &models.Collection{} 25 | 26 | collection.Name = "Glossary" 27 | collection.Type = models.CollectionTypeBase 28 | collection.Id = "glossary" 29 | collection.System = false 30 | collection.MarkAsNew() 31 | collection.Schema = schema.NewSchema( 32 | &schema.SchemaField{ 33 | Id: "glossary_expression", 34 | Name: "expression", 35 | Type: schema.FieldTypeText, 36 | Options: &schema.TextOptions{}, 37 | Presentable: true, 38 | }, 39 | &schema.SchemaField{ 40 | Id: "glorssary_definition", 41 | Name: "definition", 42 | Type: schema.FieldTypeText, 43 | Options: &schema.TextOptions{}, 44 | }, 45 | ) 46 | 47 | err := dao.SaveCollection(collection) 48 | 49 | if err != nil { 50 | return err 51 | } 52 | 53 | // read the file at ../reference/glossary_stage_1.json 54 | // unmarshal the json into a []Glossary 55 | // loop through the []Glossary 56 | // create a up query for each Glossary 57 | // execute the up query 58 | 59 | data, err := assets.InternalFiles.ReadFile("reference/glossary_stage_1.json") 60 | 61 | if err != nil { 62 | return err 63 | } 64 | 65 | var glossary []Glossary 66 | 67 | err = json.Unmarshal(data, &glossary) 68 | 69 | if err != nil { 70 | return err 71 | } 72 | 73 | for _, g := range glossary { 74 | q := db.Insert("glossary", dbx.Params{ 75 | "id": g.Id, 76 | "expression": g.Expression, 77 | "definition": g.Definition, 78 | }) 79 | 80 | _, err = q.Execute() 81 | 82 | if err != nil { 83 | return err 84 | } 85 | 86 | } 87 | 88 | return nil 89 | }, func(db dbx.Builder) error { 90 | q := db.DropTable("glossary") 91 | _, err := q.Execute() 92 | 93 | return err 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /migrations/1695699127_guestbook_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/pocketbase/dbx" 8 | "github.com/pocketbase/pocketbase/daos" 9 | m "github.com/pocketbase/pocketbase/migrations" 10 | "github.com/pocketbase/pocketbase/models" 11 | "github.com/pocketbase/pocketbase/models/schema" 12 | ) 13 | 14 | type GuestbookRecord struct { 15 | Message string `json:"message"` 16 | Name string `json:"name"` 17 | Email string `json:"email"` 18 | Location string `json:"location"` 19 | Created string `json:"created"` 20 | Updated string `json:"updated"` 21 | } 22 | 23 | func init() { 24 | m.Register(func(db dbx.Builder) error { 25 | dao := daos.New(db) 26 | 27 | collection := &models.Collection{} 28 | 29 | collection.Name = "Guestbook" 30 | collection.Id = "guestbook" 31 | collection.Type = models.CollectionTypeBase 32 | collection.System = false 33 | collection.MarkAsNew() 34 | collection.Schema = schema.NewSchema( 35 | &schema.SchemaField{ 36 | Id: "guestbooks_message", 37 | Name: "message", 38 | Type: schema.FieldTypeText, 39 | Options: &schema.TextOptions{}, 40 | }, 41 | &schema.SchemaField{ 42 | Id: "guestbooks_name", 43 | Name: "name", 44 | Type: schema.FieldTypeText, 45 | Options: &schema.TextOptions{}, 46 | Presentable: true, 47 | }, 48 | &schema.SchemaField{ 49 | Id: "guestbooks_email", 50 | Name: "email", 51 | Type: schema.FieldTypeEmail, 52 | Options: &schema.EmailOptions{}, 53 | Presentable: true, 54 | }, 55 | &schema.SchemaField{ 56 | Id: "guestbooks_location", 57 | Name: "location", 58 | Type: schema.FieldTypeText, 59 | Options: &schema.TextOptions{}, 60 | }, 61 | ) 62 | 63 | err := dao.SaveCollection(collection) 64 | 65 | if err != nil { 66 | return err 67 | } 68 | 69 | data, err := os.ReadFile("./guestbook.json") 70 | if err != nil { 71 | return dao.SaveCollection(collection) 72 | } else { 73 | var c []GuestbookRecord 74 | 75 | err = json.Unmarshal(data, &c) 76 | 77 | if err != nil { 78 | return err 79 | } 80 | 81 | for _, g := range c { 82 | q := db.Insert("guestbook", dbx.Params{ 83 | "message": g.Message, 84 | "name": g.Name, 85 | "email": g.Email, 86 | "location": g.Location, 87 | "created": g.Created, 88 | "updated": g.Updated, 89 | }) 90 | 91 | _, err = q.Execute() 92 | 93 | if err != nil { 94 | return err 95 | } 96 | 97 | } 98 | 99 | return nil 100 | } 101 | }, func(db dbx.Builder) error { 102 | // add down queries... 103 | 104 | q := db.DropTable("guestbook") 105 | _, err := q.Execute() 106 | 107 | return err 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /migrations/1695700169_default_admin.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pocketbase/dbx" 7 | "github.com/pocketbase/pocketbase/daos" 8 | m "github.com/pocketbase/pocketbase/migrations" 9 | "github.com/pocketbase/pocketbase/models" 10 | ) 11 | 12 | func init() { 13 | 14 | email := os.Getenv("WGA_ADMIN_EMAIL") 15 | password := os.Getenv("WGA_ADMIN_PASSWORD") 16 | 17 | m.Register(func(db dbx.Builder) error { 18 | 19 | if email != "" && password != "" { 20 | dao := daos.New(db) 21 | 22 | admin := &models.Admin{} 23 | admin.Email = email 24 | admin.SetPassword(password) 25 | 26 | return dao.SaveAdmin(admin) 27 | } 28 | 29 | return nil 30 | 31 | }, func(db dbx.Builder) error { 32 | if email != "" { 33 | dao := daos.New(db) 34 | 35 | admin, _ := dao.FindAdminByEmail(email) 36 | if admin != nil { 37 | return dao.DeleteAdmin(admin) 38 | } 39 | } 40 | 41 | // already deleted 42 | return nil 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /migrations/1696390260_add_art_periods_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/blackfyre/wga/assets" 7 | "github.com/blackfyre/wga/utils" 8 | "github.com/pocketbase/dbx" 9 | "github.com/pocketbase/pocketbase/daos" 10 | m "github.com/pocketbase/pocketbase/migrations" 11 | "github.com/pocketbase/pocketbase/models" 12 | "github.com/pocketbase/pocketbase/models/schema" 13 | ) 14 | 15 | type ArtPeriod struct { 16 | ID string `json:"id"` 17 | Name string `json:"name"` 18 | Start int `json:"start"` 19 | End int `json:"end"` 20 | Description string `json:"description"` 21 | } 22 | 23 | func init() { 24 | tName := "Art_periods" 25 | tId := "art_periods" 26 | m.Register(func(db dbx.Builder) error { 27 | dao := daos.New(db) 28 | 29 | collection := &models.Collection{} 30 | 31 | collection.Name = tName 32 | collection.Id = tId 33 | collection.Type = models.CollectionTypeBase 34 | collection.System = false 35 | collection.MarkAsNew() 36 | collection.Schema = schema.NewSchema( 37 | &schema.SchemaField{ 38 | Id: tId + "_name", 39 | Name: "name", 40 | Type: schema.FieldTypeText, 41 | Options: &schema.TextOptions{}, 42 | Presentable: true, 43 | }, 44 | &schema.SchemaField{ 45 | Id: "schools_slug", 46 | Name: "slug", 47 | Type: schema.FieldTypeText, 48 | Options: &schema.TextOptions{}, 49 | }, 50 | &schema.SchemaField{ 51 | Id: tId + "_start", 52 | Name: "start", 53 | Type: schema.FieldTypeNumber, 54 | Options: &schema.NumberOptions{}, 55 | }, 56 | &schema.SchemaField{ 57 | Id: tId + "_end", 58 | Name: "end", 59 | Type: schema.FieldTypeNumber, 60 | Options: &schema.NumberOptions{}, 61 | }, 62 | &schema.SchemaField{ 63 | Id: tId + "_description", 64 | Name: "description", 65 | Type: schema.FieldTypeText, 66 | Options: &schema.TextOptions{}, 67 | }, 68 | ) 69 | 70 | err := dao.SaveCollection(collection) 71 | 72 | if err != nil { 73 | return err 74 | } 75 | 76 | data, err := assets.InternalFiles.ReadFile("reference/art_periods.json") 77 | 78 | if err != nil { 79 | return err 80 | } 81 | 82 | var c []ArtPeriod 83 | 84 | err = json.Unmarshal(data, &c) 85 | 86 | if err != nil { 87 | return err 88 | } 89 | 90 | for _, g := range c { 91 | q := db.Insert(tId, dbx.Params{ 92 | "id": g.ID, 93 | "start": g.Start, 94 | "end": g.End, 95 | "name": g.Name, 96 | "description": g.Description, 97 | "slug": utils.Slugify(g.Name), 98 | }) 99 | 100 | _, err = q.Execute() 101 | 102 | if err != nil { 103 | return err 104 | } 105 | 106 | } 107 | 108 | return nil 109 | }, func(db dbx.Builder) error { 110 | q := db.DropTable(tId) 111 | _, err := q.Execute() 112 | 113 | return err 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /migrations/1696400261_add_art_forms_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/blackfyre/wga/assets" 7 | "github.com/blackfyre/wga/utils" 8 | "github.com/pocketbase/dbx" 9 | "github.com/pocketbase/pocketbase/daos" 10 | m "github.com/pocketbase/pocketbase/migrations" 11 | "github.com/pocketbase/pocketbase/models" 12 | "github.com/pocketbase/pocketbase/models/schema" 13 | ) 14 | 15 | type ArtForm struct { 16 | ID string `json:"id"` 17 | Name string `json:"name"` 18 | } 19 | 20 | func init() { 21 | tName := "Art_forms" 22 | tId := "art_forms" 23 | m.Register(func(db dbx.Builder) error { 24 | dao := daos.New(db) 25 | 26 | collection := &models.Collection{} 27 | 28 | collection.Name = tName 29 | collection.Id = tId 30 | collection.Type = models.CollectionTypeBase 31 | collection.System = false 32 | collection.MarkAsNew() 33 | collection.Schema = schema.NewSchema( 34 | &schema.SchemaField{ 35 | Id: tId + "_name", 36 | Name: "name", 37 | Type: schema.FieldTypeText, 38 | Options: &schema.TextOptions{}, 39 | Presentable: true, 40 | }, 41 | &schema.SchemaField{ 42 | Id: "schools_slug", 43 | Name: "slug", 44 | Type: schema.FieldTypeText, 45 | Options: &schema.TextOptions{}, 46 | }, 47 | ) 48 | 49 | err := dao.SaveCollection(collection) 50 | 51 | if err != nil { 52 | return err 53 | } 54 | 55 | data, err := assets.InternalFiles.ReadFile("reference/forms.json") 56 | 57 | if err != nil { 58 | return err 59 | } 60 | 61 | var c []ArtForm 62 | 63 | err = json.Unmarshal(data, &c) 64 | 65 | if err != nil { 66 | return err 67 | } 68 | 69 | for _, g := range c { 70 | q := db.Insert(tId, dbx.Params{ 71 | "id": g.ID, 72 | "name": g.Name, 73 | "slug": utils.Slugify(g.Name), 74 | }) 75 | 76 | _, err = q.Execute() 77 | 78 | if err != nil { 79 | return err 80 | } 81 | 82 | } 83 | 84 | return nil 85 | }, func(db dbx.Builder) error { 86 | q := db.DropTable(tId) 87 | _, err := q.Execute() 88 | 89 | return err 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /migrations/1696479339_add_complementary_artists.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/blackfyre/wga/assets" 8 | "github.com/pocketbase/dbx" 9 | m "github.com/pocketbase/pocketbase/migrations" 10 | ) 11 | 12 | func init() { 13 | m.Register(func(db dbx.Builder) error { 14 | data, err := assets.InternalFiles.ReadFile("reference/complementary_artists.json") 15 | 16 | if err != nil { 17 | return err 18 | } 19 | 20 | var c []Artist 21 | 22 | err = json.Unmarshal(data, &c) 23 | 24 | if err != nil { 25 | return err 26 | } 27 | 28 | for _, i := range c { 29 | q := db.Insert("artists", dbx.Params{ 30 | "id": i.Id, 31 | "name": i.Name, 32 | "bio": i.Bio, 33 | "slug": i.Slug, 34 | "year_of_birth": i.Meta.YearOfBirth, 35 | "year_of_death": i.Meta.YearOfDeath, 36 | "place_of_birth": i.Meta.PlaceOfBirth, 37 | "place_of_death": i.Meta.PlaceOfDeath, 38 | "profession": i.Source.Profession, 39 | "school": i.School, 40 | "published": true, 41 | "exact_year_of_birth": i.Meta.ExactYearOfBirth, 42 | "exact_year_of_death": i.Meta.ExactYearOfDeath, 43 | }) 44 | 45 | _, err = q.Execute() 46 | 47 | if err != nil { 48 | errString := err.Error() 49 | 50 | // if errString contains "UNIQUE constraint failed: artists.slug" then ignore 51 | // otherwise return error 52 | 53 | if !strings.Contains(errString, "UNIQUE constraint failed: Artists.slug") { 54 | return err 55 | } 56 | 57 | } 58 | 59 | } 60 | 61 | return nil 62 | }, func(db dbx.Builder) error { 63 | // add down queries... 64 | 65 | return nil 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /migrations/1696479673_add_art_types.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/blackfyre/wga/assets" 7 | "github.com/blackfyre/wga/utils" 8 | "github.com/pocketbase/dbx" 9 | "github.com/pocketbase/pocketbase/daos" 10 | m "github.com/pocketbase/pocketbase/migrations" 11 | "github.com/pocketbase/pocketbase/models" 12 | "github.com/pocketbase/pocketbase/models/schema" 13 | ) 14 | 15 | type ArtType struct { 16 | ID string `json:"id"` 17 | Name string `json:"name"` 18 | } 19 | 20 | func init() { 21 | tName := "Art_types" 22 | tId := "art_types" 23 | m.Register(func(db dbx.Builder) error { 24 | dao := daos.New(db) 25 | 26 | collection := &models.Collection{} 27 | 28 | collection.Name = tName 29 | collection.Id = tId 30 | collection.Type = models.CollectionTypeBase 31 | collection.System = false 32 | collection.MarkAsNew() 33 | collection.Schema = schema.NewSchema( 34 | &schema.SchemaField{ 35 | Id: tId + "_name", 36 | Name: "name", 37 | Type: schema.FieldTypeText, 38 | Options: &schema.TextOptions{}, 39 | Presentable: true, 40 | }, 41 | &schema.SchemaField{ 42 | Id: "schools_slug", 43 | Name: "slug", 44 | Type: schema.FieldTypeText, 45 | Options: &schema.TextOptions{}, 46 | }, 47 | ) 48 | 49 | err := dao.SaveCollection(collection) 50 | 51 | if err != nil { 52 | return err 53 | } 54 | 55 | data, err := assets.InternalFiles.ReadFile("reference/types.json") 56 | 57 | if err != nil { 58 | return err 59 | } 60 | 61 | var c []ArtType 62 | 63 | err = json.Unmarshal(data, &c) 64 | 65 | if err != nil { 66 | return err 67 | } 68 | 69 | for _, g := range c { 70 | q := db.Insert(tId, dbx.Params{ 71 | "id": g.ID, 72 | "name": g.Name, 73 | "slug": utils.Slugify(g.Name), 74 | }) 75 | 76 | _, err = q.Execute() 77 | 78 | if err != nil { 79 | return err 80 | } 81 | 82 | } 83 | 84 | return nil 85 | }, func(db dbx.Builder) error { 86 | q := db.DropTable(tId) 87 | _, err := q.Execute() 88 | 89 | return err 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /migrations/1697169726_update_settings.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | 7 | "github.com/joho/godotenv" 8 | "github.com/pocketbase/dbx" 9 | "github.com/pocketbase/pocketbase/daos" 10 | m "github.com/pocketbase/pocketbase/migrations" 11 | ) 12 | 13 | func init() { 14 | 15 | _ = godotenv.Load() 16 | 17 | m.Register(func(db dbx.Builder) error { 18 | dao := daos.New(db) 19 | 20 | settings, _ := dao.FindSettings() 21 | settings.Meta.SenderName = os.Getenv("WGA_SENDER_NAME") 22 | settings.Meta.SenderAddress = os.Getenv("WGA_SENDER_ADDRESS") 23 | settings.Smtp.Enabled = true 24 | settings.Smtp.Host = os.Getenv("WGA_SMTP_HOST") 25 | settings.Smtp.Port, _ = strconv.Atoi(os.Getenv("WGA_SMTP_PORT")) 26 | settings.Smtp.Username = os.Getenv("WGA_SMTP_USERNAME") 27 | settings.Smtp.Password = os.Getenv("WGA_SMTP_PASSWORD") 28 | 29 | return dao.SaveSettings(settings) 30 | }, nil) 31 | } 32 | -------------------------------------------------------------------------------- /migrations/1697514430_create_postcards_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/dbx" 5 | "github.com/pocketbase/pocketbase/daos" 6 | m "github.com/pocketbase/pocketbase/migrations" 7 | "github.com/pocketbase/pocketbase/models" 8 | "github.com/pocketbase/pocketbase/models/schema" 9 | ) 10 | 11 | func init() { 12 | m.Register(func(db dbx.Builder) error { 13 | dao := daos.New(db) 14 | 15 | collection := &models.Collection{} 16 | 17 | collection.Name = "Postcards" 18 | collection.Id = "postcards" 19 | collection.Type = models.CollectionTypeBase 20 | collection.System = false 21 | collection.MarkAsNew() 22 | collection.Schema = schema.NewSchema( 23 | &schema.SchemaField{ 24 | Id: "postcard_sender_name", 25 | Name: "sender_name", 26 | Type: schema.FieldTypeText, 27 | Options: &schema.TextOptions{}, 28 | Presentable: true, 29 | Required: true, 30 | }, 31 | &schema.SchemaField{ 32 | Id: "postcard_sender_email", 33 | Name: "sender_email", 34 | Type: schema.FieldTypeEmail, 35 | Options: &schema.EmailOptions{}, 36 | Required: true, 37 | }, 38 | &schema.SchemaField{ 39 | Id: "postcard_recipients", 40 | Name: "recipients", 41 | Type: schema.FieldTypeText, 42 | Options: &schema.TextOptions{}, 43 | Required: true, 44 | }, 45 | &schema.SchemaField{ 46 | Id: "postcard_message", 47 | Name: "message", 48 | Type: schema.FieldTypeEditor, 49 | Options: &schema.EditorOptions{}, 50 | Required: true, 51 | }, 52 | &schema.SchemaField{ 53 | Id: "postcard_image_id", 54 | Name: "image_id", 55 | Type: schema.FieldTypeRelation, 56 | Options: &schema.RelationOptions{ 57 | CollectionId: "artworks", 58 | MinSelect: Ptr(1), 59 | MaxSelect: Ptr(1), 60 | }, 61 | }, 62 | &schema.SchemaField{ 63 | Id: "postcard_notify_sender", 64 | Name: "notify_sender", 65 | Type: schema.FieldTypeBool, 66 | Options: schema.BoolOptions{}, 67 | }, 68 | &schema.SchemaField{ 69 | Id: "postcard_status", 70 | Name: "status", 71 | Type: schema.FieldTypeSelect, 72 | Options: &schema.SelectOptions{ 73 | Values: []string{"queued", "sent", "received"}, 74 | MaxSelect: 1, 75 | }, 76 | Presentable: true, 77 | }, 78 | &schema.SchemaField{ 79 | Id: "postcard_sent_at", 80 | Name: "sent_at", 81 | Type: schema.FieldTypeDate, 82 | Options: &schema.DateOptions{}, 83 | }, 84 | ) 85 | 86 | return dao.SaveCollection(collection) 87 | 88 | }, func(db dbx.Builder) error { 89 | q := db.DropTable("postcards") 90 | _, err := q.Execute() 91 | 92 | return err 93 | 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /migrations/1697713164_create_feedbacks_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pocketbase/dbx" 7 | "github.com/pocketbase/pocketbase/daos" 8 | m "github.com/pocketbase/pocketbase/migrations" 9 | "github.com/pocketbase/pocketbase/models" 10 | "github.com/pocketbase/pocketbase/models/schema" 11 | ) 12 | 13 | func init() { 14 | 15 | tId := "feedbacks" 16 | tName := "Feedbacks" 17 | 18 | m.Register(func(db dbx.Builder) error { 19 | dao := daos.New(db) 20 | 21 | collection := &models.Collection{} 22 | 23 | collection.Name = tName 24 | collection.Id = tId 25 | collection.Type = models.CollectionTypeBase 26 | collection.System = false 27 | collection.MarkAsNew() 28 | 29 | collection.Schema = schema.NewSchema( 30 | &schema.SchemaField{ 31 | Id: tId + "_name", 32 | Name: "name", 33 | Type: schema.FieldTypeText, 34 | Options: &schema.TextOptions{}, 35 | Presentable: true, 36 | Required: true, 37 | }, 38 | &schema.SchemaField{ 39 | Id: tId + "_email", 40 | Name: "email", 41 | Type: schema.FieldTypeEmail, 42 | Options: &schema.EmailOptions{}, 43 | Required: true, 44 | }, 45 | &schema.SchemaField{ 46 | Id: tId + "_refer_to", 47 | Name: "refer_to", 48 | Type: schema.FieldTypeUrl, 49 | Options: &schema.UrlOptions{ 50 | OnlyDomains: []string{os.Getenv("WGA_HOSTNAME")}, 51 | }, 52 | Required: true, 53 | }, 54 | &schema.SchemaField{ 55 | Id: tId + "_message", 56 | Name: "message", 57 | Type: schema.FieldTypeEditor, 58 | Options: &schema.EditorOptions{ 59 | ConvertUrls: true, 60 | }, 61 | Required: true, 62 | }, 63 | &schema.SchemaField{ 64 | Id: tId + "_handled", 65 | Name: "handled", 66 | Type: schema.FieldTypeBool, 67 | Options: &schema.BoolOptions{}, 68 | Presentable: true, 69 | }, 70 | ) 71 | 72 | return dao.SaveCollection(collection) 73 | 74 | }, func(db dbx.Builder) error { 75 | q := db.DropTable(tId) 76 | _, err := q.Execute() 77 | 78 | return err 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /migrations/1698222668_create_static_pages_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/blackfyre/wga/assets" 7 | "github.com/pocketbase/dbx" 8 | "github.com/pocketbase/pocketbase/daos" 9 | m "github.com/pocketbase/pocketbase/migrations" 10 | "github.com/pocketbase/pocketbase/models" 11 | "github.com/pocketbase/pocketbase/models/schema" 12 | ) 13 | 14 | type staticPage struct { 15 | Title string `json:"title"` 16 | Slug string `json:"slug"` 17 | Content string `json:"content"` 18 | } 19 | 20 | func init() { 21 | 22 | tId := "static_pages" 23 | tName := "Static_pages" 24 | 25 | m.Register(func(db dbx.Builder) error { 26 | dao := daos.New(db) 27 | 28 | collection := &models.Collection{} 29 | 30 | collection.Name = tName 31 | collection.Id = tId 32 | collection.Type = models.CollectionTypeBase 33 | collection.System = false 34 | collection.MarkAsNew() 35 | 36 | collection.Schema = schema.NewSchema( 37 | &schema.SchemaField{ 38 | Id: tId + "_title", 39 | Name: "title", 40 | Type: schema.FieldTypeText, 41 | Options: &schema.TextOptions{}, 42 | Presentable: true, 43 | Required: true, 44 | }, 45 | &schema.SchemaField{ 46 | Id: tId + "_slug", 47 | Name: "slug", 48 | Type: schema.FieldTypeText, 49 | Options: &schema.TextOptions{}, 50 | }, 51 | &schema.SchemaField{ 52 | Id: tId + "_content", 53 | Name: "content", 54 | Type: schema.FieldTypeEditor, 55 | Options: &schema.EditorOptions{ 56 | ConvertUrls: true, 57 | }, 58 | Required: true, 59 | }, 60 | ) 61 | 62 | err := dao.SaveCollection(collection) 63 | 64 | if err != nil { 65 | return err 66 | } 67 | 68 | data, err := assets.InternalFiles.ReadFile("reference/static_content.json") 69 | 70 | if err != nil { 71 | return err 72 | } 73 | 74 | var c []staticPage 75 | 76 | err = json.Unmarshal(data, &c) 77 | 78 | if err != nil { 79 | return err 80 | } 81 | 82 | for _, g := range c { 83 | q := db.Insert(tId, dbx.Params{ 84 | "title": g.Title, 85 | "slug": g.Slug, 86 | "content": g.Content, 87 | }) 88 | 89 | _, err = q.Execute() 90 | 91 | if err != nil { 92 | return err 93 | } 94 | 95 | } 96 | 97 | return nil 98 | 99 | }, func(db dbx.Builder) error { 100 | q := db.DropTable(tId) 101 | _, err := q.Execute() 102 | 103 | return err 104 | }) 105 | } 106 | -------------------------------------------------------------------------------- /migrations/1698736507_add_music_tables.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/blackfyre/wga/handlers" 7 | "github.com/pocketbase/dbx" 8 | "github.com/pocketbase/pocketbase/daos" 9 | m "github.com/pocketbase/pocketbase/migrations" 10 | "github.com/pocketbase/pocketbase/models" 11 | "github.com/pocketbase/pocketbase/models/schema" 12 | ) 13 | 14 | func init() { 15 | m.Register(func(db dbx.Builder) error { 16 | dao := daos.New(db) 17 | 18 | collection := &models.Collection{} 19 | 20 | collection.Name = "Music_composer" 21 | collection.Type = models.CollectionTypeBase 22 | collection.System = false 23 | collection.Id = "music_composer" 24 | collection.MarkAsNew() 25 | collection.Schema = schema.NewSchema( 26 | &schema.SchemaField{ 27 | Id: "music_composer_id", 28 | Name: "id", 29 | Type: schema.FieldTypeText, 30 | Options: &schema.TextOptions{}, 31 | }, 32 | &schema.SchemaField{ 33 | Id: "music_composer_name", 34 | Name: "name", 35 | Type: schema.FieldTypeText, 36 | Options: &schema.TextOptions{}, 37 | Presentable: true, 38 | }, 39 | &schema.SchemaField{ 40 | Id: "music_composer_century", 41 | Name: "century", 42 | Type: schema.FieldTypeSelect, 43 | Options: &schema.SelectOptions{ 44 | Values: []string{"12", "13", "14", "15", "16", "17", "18", "19", "20", "21"}, 45 | MaxSelect: 1, 46 | }, 47 | Presentable: true, 48 | }, 49 | &schema.SchemaField{ 50 | Id: "music_composer_date", 51 | Name: "date", 52 | Type: schema.FieldTypeText, 53 | Options: &schema.TextOptions{}, 54 | Presentable: true, 55 | }, 56 | &schema.SchemaField{ 57 | Id: "music_composer_language", 58 | Name: "language", 59 | Type: schema.FieldTypeText, 60 | Options: &schema.TextOptions{}, 61 | Presentable: true, 62 | }, 63 | ) 64 | 65 | err := dao.SaveCollection(collection) 66 | if err != nil { 67 | // Handle the error, for example log it and return 68 | log.Printf("Error saving collection: %v", err) 69 | return err 70 | } 71 | 72 | collection.Name = "Music_song" 73 | collection.Type = models.CollectionTypeBase 74 | collection.System = false 75 | collection.Id = "music_song" 76 | collection.MarkAsNew() 77 | collection.Schema = schema.NewSchema( 78 | &schema.SchemaField{ 79 | Id: "composer_id", 80 | Name: "composer_id", 81 | Type: schema.FieldTypeText, 82 | Options: &schema.TextOptions{}, 83 | Presentable: true, 84 | }, 85 | &schema.SchemaField{ 86 | Id: "music_song_title", 87 | Name: "title", 88 | Type: schema.FieldTypeText, 89 | Options: &schema.TextOptions{}, 90 | Presentable: true, 91 | }, 92 | &schema.SchemaField{ 93 | Id: "music_song_url", 94 | Name: "url", 95 | Type: schema.FieldTypeText, 96 | Options: &schema.TextOptions{}, 97 | }, 98 | &schema.SchemaField{ 99 | Id: "music_song_source", 100 | Name: "source", 101 | Type: schema.FieldTypeFile, 102 | Options: &schema.FileOptions{}, 103 | Presentable: true, 104 | }, 105 | ) 106 | 107 | err = dao.SaveCollection(collection) 108 | 109 | if err != nil { 110 | return err 111 | } 112 | 113 | composers := handlers.GetParsedMusics() 114 | 115 | for _, composer := range composers { 116 | 117 | q := db.Insert("music_composer", dbx.Params{ 118 | "id": composer.ID, 119 | "name": composer.Name, 120 | "date": composer.Date, 121 | "century": composer.Century, 122 | "language": composer.Language, 123 | }) 124 | 125 | _, err = q.Execute() 126 | 127 | if err != nil { 128 | return err 129 | } 130 | 131 | for _, song := range composer.Songs { 132 | q := db.Insert("music_song", dbx.Params{ 133 | "composer_id": song.ComposerID, 134 | "title": song.Title, 135 | "url": song.URL, 136 | "source": song.Source, 137 | }) 138 | 139 | _, err = q.Execute() 140 | 141 | if err != nil { 142 | return err 143 | } 144 | } 145 | 146 | } 147 | 148 | return nil 149 | }, func(db dbx.Builder) error { 150 | q := db.DropTable("music_song") 151 | _, err := q.Execute() 152 | 153 | if err != nil { 154 | log.Printf("Error executing drop music_song query: %v", err) 155 | return err 156 | } 157 | 158 | q = db.DropTable("music_composer") 159 | _, err = q.Execute() 160 | 161 | if err != nil { 162 | log.Printf("Error executing drop music_composer query: %v", err) 163 | return err 164 | } 165 | 166 | return err 167 | }) 168 | } 169 | -------------------------------------------------------------------------------- /models/artforms.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/dbx" 5 | "github.com/pocketbase/pocketbase/daos" 6 | "github.com/pocketbase/pocketbase/models" 7 | ) 8 | 9 | type ArtForm struct { 10 | models.BaseModel 11 | Name string `db:"name" json:"name"` 12 | Slug string `db:"slug" json:"slug"` 13 | } 14 | 15 | var _ models.Model = (*ArtForm)(nil) 16 | 17 | func (m *ArtForm) TableName() string { 18 | return "art_forms" // the name of your collection 19 | } 20 | 21 | // ArtFormQuery returns a new dbx.SelectQuery for the ArtForm model. 22 | func ArtFormQuery(dao *daos.Dao) *dbx.SelectQuery { 23 | return dao.ModelQuery(&ArtForm{}) 24 | } 25 | 26 | // GetArtForms retrieves all art forms from the database and returns them as a slice of ArtForm pointers. 27 | // It takes a dao object as a parameter and returns the slice of ArtForm pointers and an error (if any). 28 | func GetArtForms(dao *daos.Dao) ([]*ArtForm, error) { 29 | var c []*ArtForm 30 | err := ArtFormQuery(dao).OrderBy("name asc").All(&c) 31 | return c, err 32 | } 33 | 34 | // GetArtFormBySlug retrieves an art form from the database by its slug. 35 | // It takes a dao object and a slug string as arguments and returns a pointer to the retrieved ArtForm object and an error (if any). 36 | func GetArtFormBySlug(dao *daos.Dao, slug string) (*ArtForm, error) { 37 | var c ArtForm 38 | err := ArtFormQuery(dao).AndWhere(dbx.NewExp("LOWER(slug)={:slug}", dbx.Params{ 39 | "slug": slug, 40 | })). 41 | Limit(1). 42 | One(&c) 43 | return &c, err 44 | } 45 | -------------------------------------------------------------------------------- /models/artist.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/dbx" 5 | "github.com/pocketbase/pocketbase/daos" 6 | "github.com/pocketbase/pocketbase/models" 7 | ) 8 | 9 | // WIP - this is a work in progress 10 | type Artist struct { 11 | models.BaseModel 12 | Id string `db:"id" json:"id"` 13 | Name string `db:"name" json:"name"` 14 | Slug string `db:"slug" json:"slug"` 15 | Bio string `db:"bio" json:"bio"` 16 | YearOfBirth int `db:"year_of_birth" json:"year_of_birth"` 17 | YearOfDeath int `db:"year_of_death" json:"year_of_death"` 18 | PlaceOfBirth string `db:"place_of_birth" json:"place_of_birth"` 19 | PlaceOfDeath string `db:"place_of_death" json:"place_of_death"` 20 | Published bool `db:"published" json:"published"` 21 | School string `db:"school" json:"school"` 22 | Profession string `db:"profession" json:"profession"` 23 | } 24 | 25 | var _ models.Model = (*Artist)(nil) 26 | 27 | func (m *Artist) TableName() string { 28 | return "artists" // the name of your collection 29 | } 30 | 31 | // ArtistQuery returns a new dbx.SelectQuery for the Artist model. 32 | func ArtistQuery(dao *daos.Dao) *dbx.SelectQuery { 33 | return dao.ModelQuery(&Artist{}) 34 | } 35 | 36 | // GetArtists retrieves all art forms from the database and returns them as a slice of Artist pointers. 37 | // It takes a dao object as a parameter and returns the slice of Artist pointers and an error (if any). 38 | func GetArtists(dao *daos.Dao) ([]*Artist, error) { 39 | var c []*Artist 40 | err := ArtistQuery(dao).OrderBy("name asc").All(&c) 41 | return c, err 42 | } 43 | 44 | // GetArtistBySlug retrieves an art form from the database by its slug. 45 | // It takes a dao object and a slug string as arguments and returns a pointer to the retrieved Artist object and an error (if any). 46 | func GetArtistBySlug(dao *daos.Dao, slug string) (*Artist, error) { 47 | var c Artist 48 | err := ArtistQuery(dao).AndWhere(dbx.NewExp("LOWER(slug)={:slug}", dbx.Params{ 49 | "slug": slug, 50 | })). 51 | Limit(1). 52 | One(&c) 53 | return &c, err 54 | } 55 | 56 | func GetArtistByNameLike(dao *daos.Dao, name string) ([]*Artist, error) { 57 | var c []*Artist 58 | err := ArtistQuery(dao).AndWhere(dbx.NewExp("LOWER(name) LIKE {:name}", dbx.Params{ 59 | "name": "%" + name + "%", 60 | })).All(&c) 61 | return c, err 62 | } 63 | 64 | func GetArtistById(dao *daos.Dao, id string) (*Artist, error) { 65 | var c Artist 66 | err := ArtistQuery(dao).AndWhere(dbx.NewExp("id={:id}", dbx.Params{ 67 | "id": id, 68 | })). 69 | Limit(1). 70 | One(&c) 71 | return &c, err 72 | } 73 | -------------------------------------------------------------------------------- /models/artperiods.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/models" 5 | ) 6 | 7 | type ArtPeriod struct { 8 | models.BaseModel 9 | Name string `db:"name" json:"name"` 10 | Start int `db:"start" json:"start"` 11 | End int `db:"end" json:"end"` 12 | Description string `db:"description" json:"description"` 13 | } 14 | 15 | var _ models.Model = (*ArtPeriod)(nil) 16 | 17 | func (m *ArtPeriod) TableName() string { 18 | return "art_periods" // the name of your collection 19 | } 20 | -------------------------------------------------------------------------------- /models/arttypes.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/dbx" 5 | "github.com/pocketbase/pocketbase/daos" 6 | "github.com/pocketbase/pocketbase/models" 7 | ) 8 | 9 | type ArtType struct { 10 | models.BaseModel 11 | Name string `db:"name" json:"name"` 12 | Slug string `db:"slug" json:"slug"` 13 | } 14 | 15 | var _ models.Model = (*ArtType)(nil) 16 | 17 | func (m *ArtType) TableName() string { 18 | return "art_types" // the name of your collection 19 | } 20 | 21 | // ArtTypeQuery returns a new SelectQuery for the ArtType model. 22 | func ArtTypeQuery(dao *daos.Dao) *dbx.SelectQuery { 23 | return dao.ModelQuery(&ArtType{}) 24 | } 25 | 26 | // GetArtTypes retrieves all art types from the database and returns them as a slice of ArtType pointers. 27 | // It takes a pointer to a dao object as an argument and returns the slice of ArtType pointers and an error (if any). 28 | func GetArtTypes(dao *daos.Dao) ([]*ArtType, error) { 29 | var c []*ArtType 30 | err := ArtTypeQuery(dao).OrderBy("name asc").All(&c) 31 | return c, err 32 | } 33 | 34 | // GetArtTypeBySlug retrieves an ArtType from the database by its slug. 35 | // It takes a dao object and a slug string as parameters. 36 | // It returns a pointer to the retrieved ArtType and an error if any. 37 | func GetArtTypeBySlug(dao *daos.Dao, slug string) (*ArtType, error) { 38 | var c ArtType 39 | err := ArtTypeQuery(dao).AndWhere(dbx.NewExp("LOWER(slug)={:slug}", dbx.Params{ 40 | "slug": slug, 41 | })). 42 | Limit(1). 43 | One(&c) 44 | return &c, err 45 | } 46 | -------------------------------------------------------------------------------- /models/artworks.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/dbx" 5 | "github.com/pocketbase/pocketbase/daos" 6 | "github.com/pocketbase/pocketbase/models" 7 | ) 8 | 9 | type Artwork struct { 10 | models.BaseModel 11 | Title string `db:"title" json:"title"` 12 | Author string `db:"author" json:"author"` 13 | Form string `db:"form" json:"form"` 14 | Technique string `db:"technique" json:"technique"` 15 | School string `db:"school" json:"school"` 16 | Comment string `db:"comment" json:"comment"` 17 | Published bool `db:"published" json:"published"` 18 | Image string `db:"image" json:"image"` 19 | Type string `db:"type" json:"type"` 20 | } 21 | 22 | var _ models.Model = (*Artwork)(nil) 23 | 24 | func (m *Artwork) TableName() string { 25 | return "artworks" // the name of your collection 26 | } 27 | 28 | // ArtworkQuery returns a new dbx.SelectQuery for the Artwork model. 29 | func ArtworkQuery(dao *daos.Dao) *dbx.SelectQuery { 30 | return dao.ModelQuery(&Artwork{}) 31 | } 32 | 33 | // GetArtworks retrieves all artworks from the database. 34 | // It takes a dao object as a parameter and returns a slice of Artwork pointers and an error. 35 | // The artworks are ordered by title in ascending order. 36 | func GetArtworks(dao *daos.Dao) ([]*Artwork, error) { 37 | var c []*Artwork 38 | err := ArtworkQuery(dao).OrderBy("title asc").All(&c) 39 | return c, err 40 | } 41 | 42 | // GetRandomArtworks returns a slice of random Artwork objects from the database. 43 | // It takes a dao object and the number of items to retrieve as parameters. 44 | // It returns the slice of Artwork objects and an error, if any. 45 | func GetRandomArtworks(dao *daos.Dao, itemCount int64) ([]*Artwork, error) { 46 | var c []*Artwork 47 | err := ArtworkQuery(dao).Where(dbx.NewExp("author != \"\"")).OrderBy("RANDOM()").Limit(itemCount).All(&c) 48 | return c, err 49 | } 50 | -------------------------------------------------------------------------------- /models/composers.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/models" 5 | ) 6 | 7 | type Music_composer struct { 8 | models.BaseModel 9 | ID string `db:"id" json:"id"` 10 | Name string `db:"name" json:"name"` 11 | Date string `db:"date" json:"date"` 12 | Language string `db:"language" json:"language"` 13 | Century string `db:"century" json:"century"` 14 | Songs []Music_song `db:"songs" json:"songs"` 15 | } 16 | 17 | var _ models.Model = (*Music_composer)(nil) 18 | 19 | func (m *Music_composer) TableName() string { 20 | return "music_composer" // the name of your collection 21 | } 22 | -------------------------------------------------------------------------------- /models/feedback.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/models" 5 | ) 6 | 7 | type Feedback struct { 8 | models.BaseModel 9 | Name string `db:"name" json:"name"` 10 | Message string `db:"message" json:"message"` 11 | Email string `db:"email" json:"email"` 12 | ReferTo string `db:"refer_to" json:"refer_to"` 13 | Handled bool `db:"handled" json:"handled"` 14 | } 15 | 16 | var _ models.Model = (*Feedback)(nil) 17 | 18 | func (m *Feedback) TableName() string { 19 | return "feedbacks" // the name of your collection 20 | } 21 | -------------------------------------------------------------------------------- /models/glossary.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/models" 5 | ) 6 | 7 | type GlossaryItem struct { 8 | models.BaseModel 9 | Expression string `db:"expression" json:"expression"` 10 | Definition string `db:"definition" json:"definition"` 11 | } 12 | 13 | var _ models.Model = (*GlossaryItem)(nil) 14 | 15 | func (m *GlossaryItem) TableName() string { 16 | return "glossary" // the name of your collection 17 | } 18 | -------------------------------------------------------------------------------- /models/guestbbok.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/dbx" 5 | "github.com/pocketbase/pocketbase/daos" 6 | "github.com/pocketbase/pocketbase/models" 7 | ) 8 | 9 | type GuestbookEntry struct { 10 | models.BaseModel 11 | Name string `db:"name" json:"name"` 12 | Message string `db:"message" json:"message"` 13 | Email string `db:"email" json:"email"` 14 | Location string `db:"location" json:"location"` 15 | Created string `db:"created" json:"created"` 16 | } 17 | 18 | var _ models.Model = (*GuestbookEntry)(nil) 19 | 20 | func (m *GuestbookEntry) TableName() string { 21 | return "Guestbook" // the name of your collection 22 | } 23 | 24 | func GuestbookQuery(dao *daos.Dao) *dbx.SelectQuery { 25 | return dao.ModelQuery(&GuestbookEntry{}) 26 | } 27 | 28 | func FindEntriesForYear(dao *daos.Dao, year string) ([]*GuestbookEntry, error) { 29 | var entries []*GuestbookEntry 30 | 31 | err := GuestbookQuery(dao).AndWhere(dbx.Like("created", year)).OrderBy("created DESC").All(&entries) 32 | 33 | return entries, err 34 | } 35 | -------------------------------------------------------------------------------- /models/postcards.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/models" 5 | ) 6 | 7 | type Postcard struct { 8 | models.BaseModel 9 | SenderName string `db:"sender_name" json:"sender_name"` 10 | SenderEmail string `db:"sender_email" json:"sender_email"` 11 | Recipients string `db:"recipients" json:"recipients"` 12 | Message string `db:"message" json:"message"` 13 | } 14 | 15 | var _ models.Model = (*Postcard)(nil) 16 | 17 | func (m *Postcard) TableName() string { 18 | return "postcards" // the name of your collection 19 | } 20 | -------------------------------------------------------------------------------- /models/schools.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pocketbase/dbx" 7 | "github.com/pocketbase/pocketbase/daos" 8 | "github.com/pocketbase/pocketbase/models" 9 | ) 10 | 11 | // School represents a school model with its name and slug. 12 | type School struct { 13 | models.BaseModel 14 | Name string `db:"name" json:"name"` 15 | Slug string `db:"slug" json:"slug"` 16 | } 17 | 18 | var _ models.Model = (*School)(nil) 19 | 20 | // TableName returns the name of the collection for School model. 21 | func (m *School) TableName() string { 22 | return "schools" // the name of your collection 23 | } 24 | 25 | // SchoolQuery returns a new dbx.SelectQuery for the School model. 26 | // It takes a dao object as a parameter and returns a pointer to the new query. 27 | func SchoolQuery(dao *daos.Dao) *dbx.SelectQuery { 28 | return dao.ModelQuery(&School{}) 29 | } 30 | 31 | // GetSchools retrieves all schools from the database and returns them as a slice of School structs. 32 | // The schools are sorted by name in ascending order. 33 | func GetSchools(dao *daos.Dao) ([]*School, error) { 34 | var c []*School 35 | err := SchoolQuery(dao).OrderBy("name asc").All(&c) 36 | return c, err 37 | } 38 | 39 | // GetSchoolBySlug retrieves a school by its slug from the database. 40 | // It takes a dao object and a string slug as input parameters. 41 | // It returns a pointer to a School object and an error object. 42 | func GetSchoolBySlug(dao *daos.Dao, slug string) (*School, error) { 43 | var c School 44 | err := SchoolQuery(dao).AndWhere(dbx.NewExp("LOWER(slug)={:slug}", dbx.Params{ 45 | "slug": strings.ToLower(slug), 46 | })). 47 | Limit(1). 48 | One(&c) 49 | return &c, err 50 | } 51 | -------------------------------------------------------------------------------- /models/songs.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/models" 5 | ) 6 | 7 | type Music_song struct { 8 | models.BaseModel 9 | Title string `db:"title" json:"title"` 10 | URL string `db:"url" json:"url"` 11 | Source string `db:"source" json:"source"` 12 | ComposerID string `db:"composer_id" json:"composer_id"` // foreign key 13 | } 14 | 15 | var _ models.Model = (*Music_song)(nil) 16 | 17 | 18 | func (m *Music_song) TableName() string { 19 | return "music_song" // the name of your collection 20 | } -------------------------------------------------------------------------------- /models/staticpage.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pocketbase/dbx" 7 | "github.com/pocketbase/pocketbase/daos" 8 | "github.com/pocketbase/pocketbase/models" 9 | ) 10 | 11 | type StaticPage struct { 12 | models.BaseModel 13 | Title string `json:"title" db:"title"` 14 | Slug string `json:"slug" db:"slug"` 15 | Content string `json:"content" db:"content"` 16 | } 17 | 18 | var _ models.Model = (*StaticPage)(nil) 19 | 20 | // TableName returns the name of the collection associated with the StaticPage model. 21 | func (m *StaticPage) TableName() string { 22 | return "static_pages" 23 | } 24 | 25 | // StaticPageQuery returns a new dbx.SelectQuery for querying StaticPage models. 26 | func StaticPageQuery(dao *daos.Dao) *dbx.SelectQuery { 27 | return dao.ModelQuery(&StaticPage{}) 28 | } 29 | 30 | // FindStaticPageBySlug retrieves a StaticPage from the database by its slug. 31 | // It performs a case-insensitive match on the slug parameter. 32 | // Returns a pointer to the StaticPage and an error if any occurred. 33 | func FindStaticPageBySlug(dao *daos.Dao, slug string) (*StaticPage, error) { 34 | page := &StaticPage{} 35 | 36 | err := StaticPageQuery(dao). 37 | AndWhere(dbx.NewExp("LOWER(slug)={:slug}", dbx.Params{ 38 | "slug": strings.ToLower(slug), 39 | })). 40 | Limit(1). 41 | One(page) 42 | 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return page, nil 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wga", 3 | "version": "1.0.0", 4 | "description": "Front-end build system for the Web Gallery of Art: Project Phoenix", 5 | "private": "true", 6 | "scripts": { 7 | "build": "bun run build:css && bun run build:js", 8 | "build:js": "bun build.js", 9 | "build:css": "postcss ./resources/css/style.pcss -o ./assets/public/css/style.css", 10 | "build:watch:css": "bun run build:css -- --watch", 11 | "build:watch:js": "bun run build:js -- --watch", 12 | "dev": "concurrently -n \"templ,serve,tailwind,ts,docker\" -c \"red,magenta,yellow,blue,cyan\" \"templ generate --watch\" \"air serve --dev\" \"bun run build:watch:css\" \"bun run build:js\" \"docker compose up\"" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/blackfyre/wga.git" 17 | }, 18 | "author": "Miklós Galicz ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/blackfyre/wga/issues" 22 | }, 23 | "homepage": "https://github.com/blackfyre/wga#readme", 24 | "devDependencies": { 25 | "@playwright/test": "^1.45.2", 26 | "@tailwindcss/typography": "^0.5.13", 27 | "@types/node": "^20.14.11", 28 | "autoprefixer": "^10.4.19", 29 | "concurrently": "^8.2.2", 30 | "cssnano": "^6.1.2", 31 | "daisyui": "^4.12.10", 32 | "dotenv": "^16.4.5", 33 | "esbuild": "0.20.1", 34 | "esbuild-plugin-copy": "^2.1.1", 35 | "htmx.org": "^2.0.1", 36 | "lint-staged": "^15.2.7", 37 | "postcss": "^8.4.39", 38 | "postcss-cli": "^11.0.0", 39 | "postcss-import": "^16.1.0", 40 | "postcss-nesting": "^12.1.5", 41 | "prettier": "^3.3.3", 42 | "tailwindcss": "^3.4.6", 43 | "trix": "^2.1.4", 44 | "viewerjs": "^1.11.6" 45 | }, 46 | "lint-staged": { 47 | "**/*": "prettier --write --ignore-unknown" 48 | } 49 | } -------------------------------------------------------------------------------- /playwright-tests/artists.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("check artists page", async ({ page }) => { 4 | await page.goto("/"); 5 | 6 | // Click the get started link. 7 | await page.getByRole("link", { name: "Artists", exact: true }).click(); 8 | 9 | // expect to find "AACHEN, Hans von" on the page, in a table. 10 | 11 | await expect(page.locator("table")).toHaveText(/AACHEN, Hans von/); 12 | 13 | // use the search box to find "KOEDIJCK" 14 | await page 15 | .getByPlaceholder("Find an artist") 16 | .pressSequentially("KOEDIJCK", { delay: 100 }); 17 | 18 | // expect to find "KOEDIJCK, Isaack" on the page, in a table. 19 | await expect(page.locator("table")).toHaveText(/KOEDIJCK, Isaack/); 20 | 21 | // follow the link KOEDIJCK, Isaack 22 | await page.getByRole("link", { name: "KOEDIJCK, Isaack" }).click(); 23 | 24 | // expect to find "KOEDIJCK, Isaack" in the title. 25 | await expect(page).toHaveTitle(/KOEDIJCK, Isaack/); 26 | }); 27 | -------------------------------------------------------------------------------- /playwright-tests/artwork-search.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("artwork search", async ({ page }) => { 4 | await page.goto("/"); 5 | await page.getByRole("link", { name: "Artworks" }).click(); 6 | // expect to find h1 "Artwork search" on page 7 | await expect(page.locator("h1")).toHaveText(/Artwork search/); 8 | await page.locator("#search-result-container").click(); 9 | await page.getByRole("button", { name: "Search" }).click(); 10 | // expect to at least 1 `.card` elements in #search-result-container 11 | await expect(page.locator("#search-result-container .card")).not.toHaveCount( 12 | 0, 13 | ); 14 | }); 15 | 16 | test("artform search", async ({ page }) => { 17 | await page.goto("/artworks"); 18 | await page.locator("[name='art_form']").selectOption("painting"); 19 | await page.getByRole("button", { name: "Search" }).click(); 20 | // expect to at least 1 `.card` elements in #search-result-container 21 | await expect(page.locator("#search-result-container .card")).not.toHaveCount( 22 | 0, 23 | ); 24 | }); 25 | 26 | test("art type search", async ({ page }) => { 27 | await page.goto("/artworks"); 28 | await page.locator("[name='art_type']").selectOption("mythological"); 29 | await page.getByRole("button", { name: "Search" }).click(); 30 | // expect to at least 1 `.card` elements in #search-result-container 31 | await expect(page.locator("#search-result-container .card")).not.toHaveCount( 32 | 0, 33 | ); 34 | }); 35 | 36 | test("art school search", async ({ page }) => { 37 | await page.goto("/artworks"); 38 | await page.locator("[name='art_school']").selectOption("hungarian"); 39 | await page.getByRole("button", { name: "Search" }).click(); 40 | // expect to at least 1 `.card` elements in #search-result-container 41 | await expect(page.locator("#search-result-container .card")).not.toHaveCount( 42 | 0, 43 | ); 44 | }); 45 | 46 | test("art type and school combined search", async ({ page }) => { 47 | await page.goto("/artworks"); 48 | await page.locator("[name='art_type']").selectOption("mythological"); 49 | await page.locator("[name='art_school']").selectOption("hungarian"); 50 | await page.getByRole("button", { name: "Search" }).click(); 51 | // expect to at least 1 `.card` elements in #search-result-container 52 | await expect(page.locator("#search-result-container .card")).not.toHaveCount( 53 | 0, 54 | ); 55 | }); 56 | 57 | test("title search", async ({ page }) => { 58 | await page.goto("http://localhost:8090/artworks"); 59 | await page.locator("[name='title']").fill("Allegory"); 60 | await page.getByRole("button", { name: "Search" }).click(); 61 | await expect(page.locator("#search-result-container .card")).not.toHaveCount( 62 | 0, 63 | ); 64 | }); 65 | 66 | test("artist name search", async ({ page }) => { 67 | await page.goto("http://localhost:8090/artworks"); 68 | await page.locator("[name='artist']").fill("aachen"); 69 | await page.getByRole("button", { name: "Search" }).click(); 70 | await expect(page.locator("#search-result-container .card")).not.toHaveCount( 71 | 0, 72 | ); 73 | }); 74 | -------------------------------------------------------------------------------- /playwright-tests/feedback.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("check feedback", async ({ page }) => { 4 | await page.goto("/"); 5 | 6 | // Click the get started link. 7 | await page.getByRole("link", { name: "Feedback" }).click(); 8 | 9 | // expect dialog #d to have text "Are doing good?" 10 | await expect(page.locator("#d")).toHaveText(/Are we doing good/); 11 | 12 | await page 13 | .getByPlaceholder("Name", { exact: true }) 14 | .fill("Playwright Tester"); 15 | await page 16 | .getByPlaceholder("Email", { exact: true }) 17 | .fill("playwright.tester@local.host"); 18 | await page.getByPlaceholder("Your message").fill("I am testing your site."); 19 | 20 | // Click the submit button. 21 | await page.getByRole("button", { name: "Send feedback" }).click(); 22 | 23 | // expect notification popup: Thank you! Your feedback is valuable to us! 24 | await expect(page.locator(".toast")).toHaveText( 25 | "Thank you! Your feedback is valuable to us!", 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /playwright-tests/guestbook.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | const entry = { 4 | name: "Playwright", 5 | email: "playwright@local.host", 6 | location: "Testing Grounds", 7 | entry: "This is a test entry!", 8 | entryTest: /This is a test entry/, 9 | }; 10 | 11 | test("test", async ({ page }) => { 12 | await page.goto("/"); 13 | await page.getByRole("link", { name: "Guestbook" }).click(); 14 | await page.getByRole("link", { name: "Add Entry" }).click(); 15 | await page.getByPlaceholder("Your name", { exact: true }).fill(entry.name); 16 | await page.getByPlaceholder("Your name", { exact: true }).press("Tab"); 17 | await page.getByPlaceholder("Your email", { exact: true }).fill(entry.email); 18 | await page.getByPlaceholder("Your email", { exact: true }).press("Tab"); 19 | await page.getByPlaceholder("Your location").fill(entry.location); 20 | await page.getByPlaceholder("Your location").press("Tab"); 21 | await page.getByPlaceholder("Your entry").fill(entry.entry); 22 | await page.getByRole("button", { name: "Leave entry" }).click(); 23 | 24 | await expect(page.locator(".toast")).toHaveText(/Message added successfully/); 25 | 26 | await expect(page.locator(".gb-entries .gb-entry").nth(0)).toHaveText( 27 | entry.entryTest, 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /playwright-tests/postcard.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("send postcard", async ({ page }) => { 4 | await page.goto("/artists/koedijck-isaack-3ed9e200b9e8252"); 5 | await page.getByRole("link", { name: "Send postcard" }).click(); 6 | 7 | await expect(page.locator("#d")).toBeVisible(); 8 | 9 | await expect(page.locator("#d")).toHaveText(/Write a postcard/); 10 | 11 | await page.locator("[name='sender_name']").fill("Playwright Tester"); 12 | await page 13 | .locator("[name='sender_email']") 14 | .fill("playwright.tester@local.host"); // this is the postcard sender's email 15 | await page 16 | .locator("[name='recipients[]']") 17 | .fill("playwright.tester@local.host"); // this is the postcard recipient's email 18 | await page.locator("trix-editor").fill("I am testing your site."); 19 | 20 | await page.getByRole("button", { name: "Send postcard" }).click(); 21 | 22 | await expect(page.locator(".toast")).toHaveText( 23 | /Thank you! Your postcard has been queued for sending!/, 24 | ); 25 | 26 | const mailpitUrl = process.env.MAILPIT_URL; 27 | if (!mailpitUrl) { 28 | throw new Error("MAILPIT_URL environment variable is not set."); 29 | } 30 | await page.goto(mailpitUrl); 31 | 32 | try { 33 | await page 34 | .getByRole("link", { name: "WGA playwright.tester@local." }) 35 | .nth(0) 36 | .click({ 37 | timeout: 90 * 60 * 1000, 38 | }); 39 | } catch (e) { 40 | console.error("Error: ", e); 41 | page.reload(); 42 | await page 43 | .getByRole("link", { name: "WGA playwright.tester@local." }) 44 | .nth(0) 45 | .click({ 46 | timeout: 90 * 60 * 1000, 47 | }); 48 | } 49 | 50 | const postcardLink = await page 51 | .frameLocator("#preview-html") 52 | .getByRole("link", { name: "Pickup my Postcard!" }) 53 | .getAttribute("href", { timeout: 90000 }); 54 | 55 | await page.getByRole("button", { name: /Delete/ }).click(); 56 | 57 | if (!postcardLink) { 58 | throw new Error("Postcard link not found"); 59 | } 60 | 61 | console.log("Postcard link: ", postcardLink); 62 | 63 | await page.goto(postcardLink); 64 | await expect(page.locator("#mc-area")).toContainText([ 65 | "I am testing your site", 66 | ]); 67 | }); 68 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | require("dotenv").config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: "./playwright-tests", 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | // workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: process.env.CI ? [["blob"], ["github"]] : "html", 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | globalTimeout: process.env.CI ? 4.8 * 60 * 1000 : 10 * 60 * 1000, 26 | use: { 27 | /* Base URL to use in actions like `await page.goto('/')`. */ 28 | baseURL: `${process.env.WGA_PROTOCOL}://${process.env.WGA_HOSTNAME}`, 29 | 30 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 31 | trace: "retain-on-failure", 32 | screenshot: "only-on-failure", 33 | video: "retain-on-failure", 34 | viewport: { width: 1920, height: 1080 }, 35 | }, 36 | 37 | /* Configure projects for major browsers */ 38 | projects: [ 39 | { 40 | name: "chromium", 41 | use: { ...devices["Desktop Chrome"] }, 42 | }, 43 | 44 | // { 45 | // name: 'firefox', 46 | // use: { ...devices['Desktop Firefox'] }, 47 | // }, 48 | 49 | // { 50 | // name: 'webkit', 51 | // use: { ...devices['Desktop Safari'] }, 52 | // }, 53 | 54 | /* Test against mobile viewports. */ 55 | // { 56 | // name: 'Mobile Chrome', 57 | // use: { ...devices['Pixel 5'] }, 58 | // }, 59 | // { 60 | // name: 'Mobile Safari', 61 | // use: { ...devices['iPhone 12'] }, 62 | // }, 63 | 64 | /* Test against branded browsers. */ 65 | // { 66 | // name: 'Microsoft Edge', 67 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 68 | // }, 69 | // { 70 | // name: 'Google Chrome', 71 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 72 | // }, 73 | ], 74 | 75 | /* Run your local dev server before starting the tests */ 76 | // webServer: { 77 | // command: "./wga serve --dev", 78 | // url: "http://localhost:8090", 79 | // reuseExistingServer: !process.env.CI, 80 | // }, 81 | }); 82 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "tailwindcss/nesting": "postcss-nesting", 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | ...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}), 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /resources/mjml/postcard_notification.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{.Title}} 7 | 8 | 9 | 10 | 11 | 19 | 20 | Postcard for you! 21 | Hello There! 22 | {{.SenderName}} has left postcard for you to pick up at the Web 24 | Gallery of Art! 26 | 27 | 32 | Pickup my Postcard! 33 | 34 | 35 | The postcard will be indefinitely available to you with the 37 | link. 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./resources/{css,js}/*.{html,js,ts}", 5 | "./assets/templ/**/*.templ", 6 | "utils/**/*.go", 7 | ], 8 | theme: { 9 | extend: {}, 10 | fontFamily: { 11 | sans: ["Lexend", "Arial", "sans-serif"], 12 | serif: ["Merriweather", "Georgia", "serif"], 13 | mono: ["JetBrains Mono", "monospace"], 14 | }, 15 | container: { 16 | center: true, 17 | padding: "1rem", 18 | }, 19 | }, 20 | plugins: [require("daisyui"), require("@tailwindcss/typography")], 21 | daisyui: { 22 | themes: [ 23 | { 24 | light: { 25 | ...require("daisyui/src/theming/themes")["light"], 26 | primary: "#013365", 27 | secondary: "#489393", 28 | base100: "#f0f6ff", 29 | }, 30 | }, 31 | { 32 | dark: { 33 | ...require("daisyui/src/theming/themes")["dark"], 34 | primary: "#013365", 35 | secondary: "#489393", 36 | }, 37 | }, 38 | ], // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"] 39 | darkTheme: "dark", // name of one of the included themes for dark mode 40 | base: true, // applies background color and foreground color for root element by default 41 | styled: true, // include daisyUI colors and design decisions for all components 42 | utils: true, // adds responsive and modifier utility classes 43 | prefix: "", // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors) 44 | logs: true, // Shows info about daisyUI version and used config in the console when building your CSS 45 | themeRoot: ":root", // The element that receives theme color CSS variables 46 | }, 47 | safelist: [ 48 | { 49 | pattern: /alert-.+/, 50 | }, 51 | ], 52 | }; 53 | -------------------------------------------------------------------------------- /utils/htmx.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | 7 | "github.com/labstack/echo/v5" 8 | ) 9 | 10 | func SetHxTrigger(c echo.Context, data map[string]any) { 11 | hd, err := json.Marshal(data) 12 | 13 | if err != nil { 14 | log.Fatalln(err) 15 | } 16 | 17 | c.Response().Header().Set("HX-Trigger", string(hd)) 18 | } 19 | 20 | func SendToastMessage(message string, t string, closeDialog bool, c echo.Context, trigger string) { 21 | payload := struct { 22 | Message string `json:"message"` 23 | Type string `json:"type"` 24 | CloseDialog bool `json:"closeDialog"` 25 | }{ 26 | Message: message, 27 | Type: t, 28 | CloseDialog: closeDialog, 29 | } 30 | 31 | m := map[string]any{ 32 | "notification:toast": payload, 33 | } 34 | 35 | if trigger != "" { 36 | m[trigger] = trigger 37 | } 38 | 39 | SetHxTrigger(c, m) 40 | } 41 | -------------------------------------------------------------------------------- /utils/jsonld/main.go: -------------------------------------------------------------------------------- 1 | package jsonld 2 | 3 | import ( 4 | "fmt" 5 | 6 | wgaModels "github.com/blackfyre/wga/models" 7 | "github.com/blackfyre/wga/utils" 8 | "github.com/labstack/echo/v5" 9 | "github.com/pocketbase/pocketbase/models" 10 | ) 11 | 12 | // generateArtistJsonLdContent generates a JSON-LD content for an artist record. 13 | // It takes a pointer to a models.Record and an echo.Context as input and returns a map[string]any. 14 | // The returned map contains the JSON-LD content for the artist record, including the artist's name, URL, profession, 15 | // birth and death dates, and birth and death places (if available). 16 | // Deprecated: Use ArtistJsonLd instead. 17 | func GenerateArtistJsonLdContent(r *wgaModels.Artist, c echo.Context) map[string]any { 18 | 19 | fullUrl := c.Scheme() + "://" + c.Request().Host + "/artists/" + r.Slug + "-" + r.Id 20 | 21 | d := map[string]any{ 22 | "@context": "https://schema.org", 23 | "@type": "Person", 24 | "name": r.Name, 25 | "url": fullUrl, 26 | "hasOccupation": r.Profession, 27 | } 28 | 29 | if r.YearOfBirth > 0 { 30 | d["birthDate"] = r.YearOfBirth 31 | } 32 | 33 | if r.YearOfDeath > 0 { 34 | d["deathDate"] = r.YearOfDeath 35 | } 36 | 37 | if r.PlaceOfBirth != "" { 38 | d["birthPlace"] = map[string]string{ 39 | "@type": "Place", 40 | "name": r.PlaceOfBirth, 41 | } 42 | } 43 | 44 | if r.PlaceOfDeath != "" { 45 | d["deathPlace"] = map[string]string{ 46 | "@type": "Place", 47 | "name": r.PlaceOfDeath, 48 | } 49 | } 50 | 51 | return d 52 | } 53 | 54 | // ArtistJsonLd generates a JSON-LD representation of an artist. 55 | // It takes an instance of wgaModels.Artist and an echo.Context as input. 56 | // It returns a Person struct representing the artist in JSON-LD format. 57 | func ArtistJsonLd(r *wgaModels.Artist, c echo.Context) Person { 58 | return newPerson(Person{ 59 | Name: r.Name, 60 | Url: c.Scheme() + "://" + c.Request().Host + "/artists/" + r.Slug + "-" + r.Id, 61 | BirthDate: fmt.Sprint(r.YearOfBirth), 62 | DeathDate: fmt.Sprint(r.YearOfDeath), 63 | PlaceOfBirth: newPlace(Place{ 64 | Name: r.PlaceOfBirth, 65 | }), 66 | PlaceOfDeath: newPlace(Place{ 67 | Name: r.PlaceOfDeath, 68 | }), 69 | HasOccupation: newOccupation(Occupation{ 70 | Name: r.Profession, 71 | }), 72 | Description: utils.StrippedHTML(r.Bio), 73 | }) 74 | } 75 | 76 | // generateVisualArtworkJsonLdContent generates a map containing JSON-LD content for a visual artwork record. 77 | // It takes a models.Record pointer and an echo.Context as input and returns a map[string]any. 78 | func GenerateVisualArtworkJsonLdContent(r *models.Record, c echo.Context) map[string]any { 79 | 80 | d := map[string]any{ 81 | "@context": "https://schema.org", 82 | "@type": "VisualArtwork", 83 | "name": r.GetString("name"), 84 | "description": utils.StrippedHTML(r.GetString("comment")), 85 | "artform": r.GetString("technique"), 86 | } 87 | 88 | return d 89 | } 90 | 91 | func ArtworkJsonLd(r *models.Record, a *wgaModels.Artist, c echo.Context) VisualArtwork { 92 | return VisualArtwork{ 93 | Name: r.GetString("name"), 94 | Description: utils.StrippedHTML(r.GetString("comment")), 95 | Artform: r.GetString("technique"), 96 | Url: c.Scheme() + "://" + c.Request().Host + "/artworks/" + r.GetString("slug") + "-" + r.GetId(), 97 | Artist: ArtistJsonLd(a, c), 98 | ArtMedium: r.GetString("medium"), 99 | Image: ImageObject{ 100 | Image: c.Scheme() + "://" + c.Request().Host + "/images/" + r.GetString("image"), 101 | }, 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /utils/jsonld/types.go: -------------------------------------------------------------------------------- 1 | package jsonld 2 | 3 | // Person represents a person entity in JSON-LD format. 4 | type Person struct { 5 | Context string `json:"@context,omitempty"` 6 | Type string `json:"@type,omitempty"` 7 | Name string `json:"name,omitempty"` 8 | Url string `json:"url,omitempty"` 9 | BirthDate string `json:"birthDate,omitempty"` 10 | DeathDate string `json:"deathDate,omitempty"` 11 | PlaceOfBirth Place `json:"birthPlace,omitempty"` 12 | PlaceOfDeath Place `json:"deathPlace,omitempty"` 13 | Description string `json:"description,omitempty"` 14 | HasOccupation Occupation `json:"hasOccupation,omitempty"` 15 | } 16 | 17 | // Place represents a place entity in JSON-LD format. 18 | type Place struct { 19 | Context string `json:"@context,omitempty"` 20 | Type string `json:"@type,omitempty"` 21 | Name string `json:"name,omitempty"` 22 | } 23 | 24 | // Occupation represents an occupation entity in JSON-LD format. 25 | type Occupation struct { 26 | Context string `json:"@context,omitempty"` 27 | Type string `json:"@type,omitempty"` 28 | Name string `json:"name,omitempty"` 29 | } 30 | 31 | // VisualArtwork represents a visual artwork entity in JSON-LD format. 32 | type VisualArtwork struct { 33 | Context string `json:"@context,omitempty"` 34 | Type string `json:"@type,omitempty"` 35 | Name string `json:"name,omitempty"` 36 | Description string `json:"description,omitempty"` 37 | Url string `json:"url,omitempty"` 38 | Artform string `json:"artform,omitempty"` 39 | Artist Person `json:"artist,omitempty"` 40 | ArtMedium string `json:"artMedium,omitempty"` 41 | Image ImageObject `json:"image,omitempty"` 42 | } 43 | 44 | // ImageObject represents an image object entity in JSON-LD format. 45 | type ImageObject struct { 46 | Context string `json:"@context,omitempty"` 47 | Type string `json:"@type,omitempty"` 48 | Name string `json:"name,omitempty"` 49 | Caption string `json:"caption,omitempty"` 50 | Image string `json:"image,omitempty"` 51 | ThumbnailUrl string `json:"thumbnailUrl,omitempty"` 52 | } 53 | 54 | // newPerson returns a new Person object with the given Person object. 55 | func newPerson(p Person) Person { 56 | p.Context = "https://schema.org" 57 | p.Type = "Person" 58 | return p 59 | } 60 | 61 | // newPlace returns a new Place object with the given Place object. 62 | func newPlace(p Place) Place { 63 | p.Context = "https://schema.org" 64 | p.Type = "Place" 65 | return p 66 | } 67 | 68 | // newOccupation returns a new Occupation object with the given Occupation object. 69 | func newOccupation(o Occupation) Occupation { 70 | o.Context = "https://schema.org" 71 | o.Type = "Occupation" 72 | return o 73 | } 74 | 75 | // newVisualArtwork returns a new VisualArtwork object with the given VisualArtwork object. 76 | func newVisualArtwork(v VisualArtwork) VisualArtwork { 77 | v.Context = "https://schema.org" 78 | v.Type = "VisualArtwork" 79 | return v 80 | } 81 | -------------------------------------------------------------------------------- /utils/listMusicUrls.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type UrlData struct { 12 | Url string 13 | ID uuid.UUID 14 | } 15 | 16 | type Song struct { 17 | ID uuid.UUID 18 | Title string 19 | URL string 20 | Source []string 21 | } 22 | 23 | type Composer struct { 24 | Name string 25 | Date string 26 | Language string 27 | Songs []Song 28 | } 29 | 30 | type Century struct { 31 | Century string 32 | Composers []Composer 33 | } 34 | 35 | func ParseMusicListToUrls(filePath string) ([]UrlData, error) { 36 | fmt.Println("Parsing music list to urls...") 37 | 38 | var data []Century 39 | 40 | // Read the data from the file 41 | fileData, err := os.ReadFile(filePath) 42 | 43 | if err != nil { 44 | fmt.Println("Error reading file:", err) 45 | return nil, err 46 | } 47 | 48 | // Unmarshal the JSON data into the data variable 49 | err = json.Unmarshal(fileData, &data) 50 | if err != nil { 51 | fmt.Println("Error unmarshalling JSON data:", err) 52 | return nil, err 53 | } 54 | 55 | var parsedData []UrlData 56 | for _, century := range data { 57 | for _, composer := range century.Composers { 58 | for _, song := range composer.Songs { 59 | if len(song.Source) > 0 { 60 | for _, source := range song.Source { 61 | urlData := UrlData{ 62 | Url: source, 63 | ID: uuid.New(), 64 | } 65 | parsedData = append(parsedData, urlData) 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | fmt.Println("Done parsing music list to urls.") 73 | 74 | // Write the parsed data to a JSON file 75 | file, err := os.Create("musicUrls.json") 76 | if err != nil { 77 | fmt.Println("Error creating file:", err) 78 | return nil, err 79 | } 80 | 81 | defer func() { 82 | if cerr := file.Close(); cerr != nil { 83 | fmt.Println("Error closing file:", cerr) 84 | } 85 | }() 86 | 87 | jsonData, err := json.Marshal(parsedData) 88 | if err != nil { 89 | fmt.Println("Error marshalling JSON data:", err) 90 | return nil, err 91 | } 92 | 93 | _, err = file.Write(jsonData) 94 | if err != nil { 95 | fmt.Println("Error writing JSON data to file:", err) 96 | return nil, err 97 | } 98 | 99 | return parsedData, nil 100 | } 101 | -------------------------------------------------------------------------------- /utils/middleware.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/labstack/echo/v5" 5 | ) 6 | 7 | func IsHtmxRequestMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 8 | return func(c echo.Context) error { 9 | if c.Request().Header.Get("HX-Request") != "true" { 10 | return ServerFaultError(c) 11 | } 12 | 13 | return next(c) // proceed with the request chain 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /utils/seed/images.go: -------------------------------------------------------------------------------- 1 | package seed 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/blackfyre/wga/assets" 10 | "github.com/blackfyre/wga/models" 11 | "github.com/pocketbase/pocketbase" 12 | "github.com/pocketbase/pocketbase/tools/filesystem" 13 | ) 14 | 15 | func SeedImages(app *pocketbase.PocketBase) error { 16 | 17 | portraitLocal, err := assets.InternalFiles.ReadFile("reference/wga_placeholder_portrait.jpg") 18 | 19 | if err != nil { 20 | fmt.Println(err.Error()) 21 | return err 22 | } 23 | 24 | landscapeLocal, err := assets.InternalFiles.ReadFile("reference/wga_placeholder_landscape.jpg") 25 | 26 | if err != nil { 27 | fmt.Println(err.Error()) 28 | return err 29 | } 30 | 31 | rfs, err := app.NewFilesystem() 32 | 33 | if err != nil { 34 | return err 35 | } 36 | 37 | defer func() { 38 | err = rfs.Close() 39 | 40 | if err != nil { 41 | fmt.Println(err.Error()) 42 | return 43 | } 44 | }() 45 | 46 | artworks, err := models.GetArtworks(app.Dao()) 47 | 48 | if err != nil { 49 | return err 50 | } 51 | 52 | awc := len(artworks) 53 | 54 | log.Printf("Found %d artworks", awc) 55 | 56 | // start overall timer here 57 | startTime := time.Now() 58 | 59 | // circular buffer for the last 10 times 60 | var lastTenTimes [10]time.Duration 61 | 62 | for i, artwork := range artworks { 63 | // timer start here 64 | jobStart := time.Now() 65 | 66 | uploadKey := fmt.Sprintf("artworks/%s/%s", artwork.Id, artwork.Image) 67 | 68 | var img []byte 69 | 70 | // Randomly generate a number between 1 and 10 71 | randomNumber := rand.Intn(10) + 1 72 | 73 | // If the number is even, use the portrait image 74 | if randomNumber%2 == 0 { 75 | img = portraitLocal 76 | } else { 77 | img = landscapeLocal 78 | } 79 | 80 | err = rfs.Upload(img, uploadKey) 81 | 82 | if err != nil { 83 | fmt.Println(err.Error()) 84 | return err 85 | } 86 | 87 | err = generateThumbnail(artwork, rfs, "100x100") 88 | 89 | if err != nil { 90 | fmt.Println(err.Error()) 91 | return err 92 | } 93 | 94 | err = generateThumbnail(artwork, rfs, "320x240") 95 | 96 | if err != nil { 97 | fmt.Println(err.Error()) 98 | return err 99 | } 100 | 101 | lastTenTimes[i%10] = time.Since(jobStart) 102 | 103 | if i%200 == 0 { 104 | log.Printf("Uploaded %d images", i) 105 | log.Printf("Elapsed time: %s", time.Since(startTime)) 106 | 107 | var lastTenTimesAverage time.Duration 108 | 109 | for _, t := range lastTenTimes { 110 | lastTenTimesAverage += t 111 | } 112 | 113 | lastTenTimesAverage = lastTenTimesAverage / 10 114 | 115 | log.Printf("Average job time: %s", lastTenTimesAverage) 116 | 117 | log.Printf("Estimated time remaining: %s", lastTenTimesAverage*time.Duration(awc-i)) 118 | } 119 | } 120 | 121 | // end overall timer here 122 | endTime := time.Now() 123 | 124 | // calculate overall time here 125 | elapsed := endTime.Sub(startTime) 126 | 127 | log.Printf("Elapsed time: %s", elapsed) 128 | 129 | return nil 130 | } 131 | 132 | // generateThumbnail generates a thumbnail for the given artwork. 133 | // It takes an Artwork pointer, a System pointer, and a size string as parameters. 134 | // The uploadKey is generated using the artwork's ID and image name. 135 | // It creates a thumbnail using the CreateThumb method of the System object. 136 | // The thumbnail is saved with a filename that includes the size and original image name. 137 | // If an error occurs during the thumbnail generation, it is printed and returned. 138 | // Otherwise, nil is returned. 139 | func generateThumbnail(aw *models.Artwork, rfs *filesystem.System, size string) error { 140 | 141 | uploadKey := fmt.Sprintf("artworks/%s/%s", aw.Id, aw.Image) 142 | 143 | err := rfs.CreateThumb(uploadKey, fmt.Sprintf("artworks/%s/thumb_%s/%s_%s", aw.Id, aw.Image, size, aw.Image), size) 144 | 145 | if err != nil { 146 | fmt.Println(err.Error()) 147 | return err 148 | } 149 | 150 | return nil 151 | 152 | } 153 | -------------------------------------------------------------------------------- /utils/sitemap/main.go: -------------------------------------------------------------------------------- 1 | package sitemap 2 | 3 | import ( 4 | "fmt" 5 | "github.com/blackfyre/wga/utils/url" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/pocketbase/pocketbase" 11 | "github.com/pocketbase/pocketbase/models" 12 | "github.com/sabloger/sitemap-generator/smg" 13 | ) 14 | 15 | // setupSitemapIndex initializes and configures a SitemapIndex object. 16 | // It sets the SitemapIndex name, hostname, output path, server URI, and compression settings. 17 | // The SitemapIndex object is then returned. 18 | func setupSitemapIndex() *smg.SitemapIndex { 19 | isDevelopment := os.Getenv("WGA_ENV") == "development" 20 | index := smg.NewSitemapIndex(isDevelopment) 21 | index.SetSitemapIndexName("web_gallery_of_art") 22 | index.SetHostname(os.Getenv("WGA_PROTOCOL") + "://" + os.Getenv("WGA_HOSTNAME")) 23 | index.SetOutputPath("./wga_sitemap") 24 | index.SetServerURI("/sitemaps/") 25 | 26 | index.SetCompress(!isDevelopment) 27 | 28 | return index 29 | } 30 | 31 | func GenerateSiteMap(app *pocketbase.PocketBase) { 32 | 33 | index := setupSitemapIndex() 34 | 35 | generateArtistMap(app, index) 36 | generateArtworksMap(app, index) 37 | 38 | // Save func saves the xml files and returns more than one filename in case of split large files. 39 | filenames, err := index.Save() 40 | if err != nil { 41 | app.Logger().Error("Unable to Save Sitemap:", err) 42 | return 43 | } 44 | for _, filename := range filenames { 45 | app.Logger().Info(fmt.Sprintf("Sitemap saved to %c", filename)) 46 | } 47 | } 48 | 49 | func setupSitemap(name string, index *smg.SitemapIndex) *smg.Sitemap { 50 | now := time.Now().UTC() 51 | sitemap := index.NewSitemap() 52 | sitemap.SetName(name) 53 | sitemap.SetLastMod(&now) 54 | return sitemap 55 | } 56 | 57 | func fetchArtistsForSitemap(app *pocketbase.PocketBase) ([]*models.Record, error) { 58 | return app.Dao().FindRecordsByFilter( 59 | "artists", 60 | "published = true", 61 | "+name", 62 | 0, 63 | 0, 64 | ) 65 | } 66 | 67 | func generateArtistMap(app *pocketbase.PocketBase, index *smg.SitemapIndex) { 68 | sitemap := setupSitemap("artists", index) 69 | 70 | records, err := fetchArtistsForSitemap(app) 71 | 72 | if err != nil { 73 | app.Logger().Error("Error fetching artists for sitemap", err) 74 | } 75 | 76 | for _, m := range records { 77 | 78 | updatedAtTime := m.GetUpdated().Time() 79 | 80 | err := sitemap.Add(&smg.SitemapLoc{ 81 | Loc: url.GenerateArtistUrlFromRecord(m), 82 | LastMod: &updatedAtTime, 83 | ChangeFreq: smg.Monthly, 84 | Priority: 0.8, 85 | }) 86 | 87 | if err != nil { 88 | log.Fatal("Unable to Save Sitemap:", err) 89 | } 90 | } 91 | } 92 | 93 | func generateArtworksMap(app *pocketbase.PocketBase, index *smg.SitemapIndex) { 94 | sitemap := setupSitemap("artworks", index) 95 | 96 | records, err := app.Dao().FindRecordsByFilter( 97 | "artworks", 98 | "published = true", 99 | "+title", 100 | 0, 101 | 0, 102 | ) 103 | 104 | if err != nil { 105 | log.Fatal("Unable to Save Sitemap:", err) 106 | } 107 | 108 | for _, m := range records { 109 | 110 | if errs := app.Dao().ExpandRecord(m, []string{"author"}, nil); len(errs) > 0 { 111 | app.Logger().Error("Error expanding record", "err", errs) 112 | // we should log the failed items, still waiting for pb logs 113 | continue // we're skipping failed items 114 | } 115 | 116 | author := m.ExpandedOne("author") 117 | 118 | if author == nil { 119 | //every item in the db should have an author 120 | // log those items which don't for fixing 121 | app.Logger().Error("Error expanding record, no author found", "id", m.GetId()) 122 | continue 123 | } 124 | 125 | updatedAtTime := m.GetUpdated().Time() 126 | 127 | err := sitemap.Add(&smg.SitemapLoc{ 128 | Loc: url.GenerateArtworkUrl(url.ArtworkUrlDTO{ 129 | ArtistName: author.GetString("name"), 130 | ArtistId: author.GetId(), 131 | ArtworkId: m.GetId(), 132 | ArtworkTitle: m.GetString("title"), 133 | }), 134 | LastMod: &updatedAtTime, 135 | ChangeFreq: smg.Monthly, 136 | Priority: 0.8, 137 | }) 138 | 139 | if err != nil { 140 | app.Logger().Error("Unable to Save Sitemap:", err) 141 | return 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /utils/url.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | func AssetUrl(path string) string { 9 | 10 | protocol := os.Getenv("WGA_PROTOCOL") 11 | hostname := os.Getenv("WGA_HOSTNAME") 12 | 13 | // if the path beings with a slash, remove it 14 | if !strings.HasPrefix(path, "/") { 15 | path = "/" + path 16 | } 17 | 18 | return protocol + "://" + hostname + path 19 | } 20 | 21 | // ExtractIdFromString extracts the ID from a string. 22 | func ExtractIdFromString(s string) string { 23 | parts := strings.Split(s, "-") 24 | return parts[len(parts)-1] 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /utils/url/main.go: -------------------------------------------------------------------------------- 1 | package url 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/blackfyre/wga/utils" 8 | "github.com/labstack/echo/v5" 9 | "github.com/pocketbase/pocketbase/models" 10 | ) 11 | 12 | func GenerateFileUrl(collection string, collectionId string, fileName string, token string) string { 13 | 14 | return fmt.Sprintf( 15 | "/api/files/%s/%s/%s?token=%s", 16 | collection, 17 | collectionId, 18 | fileName, 19 | url.QueryEscape(token), 20 | ) 21 | } 22 | 23 | func GenerateThumbUrl(collection string, collectionId string, fileName string, thumbSize string, token string) string { 24 | 25 | return fmt.Sprintf( 26 | "/api/files/%s/%s/%s?token=%s&thumb=%s", 27 | collection, 28 | collectionId, 29 | fileName, 30 | url.QueryEscape(token), 31 | thumbSize, 32 | ) 33 | } 34 | 35 | type ArtworkUrlDTO struct { 36 | ArtistName string 37 | ArtistId string 38 | ArtworkTitle string 39 | ArtworkId string 40 | BaseUrl string 41 | } 42 | 43 | func GenerateArtworkUrl(d ArtworkUrlDTO) string { 44 | return fmt.Sprintf("%v/artists/%v-%v/artworks/%v-%v", d.BaseUrl, utils.Slugify(d.ArtistName), d.ArtistId, utils.Slugify(d.ArtistName), d.ArtworkId) 45 | } 46 | 47 | func GenerateArtistUrlFromRecord(r *models.Record) string { 48 | return GenerateArtistUrl(ArtistUrlDTO{ 49 | ArtistName: r.GetString("name"), 50 | ArtistId: r.Id, 51 | }) 52 | } 53 | 54 | type ArtistUrlDTO struct { 55 | ArtistName string 56 | ArtistId string 57 | BaseUrl string 58 | } 59 | 60 | func GenerateArtistUrl(d ArtistUrlDTO) string { 61 | return fmt.Sprintf("%v/artists/%v-%v", d.BaseUrl, utils.Slugify(d.ArtistName), d.ArtistId) 62 | } 63 | 64 | func GetRequiredQueryParam(c echo.Context, param string) (string, error) { 65 | p := c.QueryParam(param) 66 | 67 | if p == "" { 68 | return "", fmt.Errorf("Missing required query parameter: %v", param) 69 | } 70 | 71 | return p, nil 72 | } 73 | -------------------------------------------------------------------------------- /utils/zst.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/klauspost/compress/zstd" 7 | ) 8 | 9 | func Decompress(in io.Reader, out io.Writer) error { 10 | d, err := zstd.NewReader(in) 11 | if err != nil { 12 | return err 13 | } 14 | defer d.Close() 15 | 16 | // Copy content... 17 | _, err = io.Copy(out, d) 18 | return err 19 | } 20 | --------------------------------------------------------------------------------