mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-03-24 09:11:43 -04:00
Enhance money parsing, profile stats, and user experience (#1057)
* Enhance money parsing and normalization in BackupViewSet * Refactor money parsing in BackupViewSet for schema safety and enhance profile statistics display with new metrics * Improve throttling handling in auth hooks to enhance user experience during high-load scenarios * fix(deps): update countries-states-cities-database v3.1 (#1047) update countries-states-cities-database to fixed some cities error * fix: update appVersion to v0.12.0-main-031526 * feat: enhance CategoryFilterDropdown with event dispatching and URL synchronization. Fixes [BUG] Category Filter not working in v0.12.0 Fixes #990 * feat(profile): add record holders for activities and display details in profile page * feat: restructure issue templates and enhance contribution guidelines * Potential fix for code scanning alert no. 50: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for code scanning alert no. 51: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: 橙 <chengjunchao@hotmail.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Found something not working as expected? Please file a detailed bug report so we can fix it quickly!
|
||||
title: "[BUG] <short description>"
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
🛑 **Note**: Please search existing issues before filing a new one. This helps avoid duplicates and ensures we can address your concern efficiently!
|
||||
|
||||
## 🐞 Bug Description
|
||||
|
||||
A clear and concise description of the issue. What went wrong?
|
||||
|
||||
## 🔄 Steps to Reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4. Observe the error
|
||||
|
||||
## ✅ Expected Behavior
|
||||
|
||||
What did you expect AdventureLog to do?
|
||||
|
||||
## 📸 Screenshots / Logs
|
||||
|
||||
- Attach screenshots if it helps explain the problem.
|
||||
- If possible, include **relevant log excerpts** (be sure to remove sensitive info).
|
||||
|
||||
## 🐳 Environment Details
|
||||
|
||||
- **Platform:** (Docker, Synology, Proxmox, TrueNAS, Unraid, etc.)
|
||||
- **Install Method:** (Docker Compose, Quick Install Script, Manual, etc.)
|
||||
- **AdventureLog Version:** (e.g., v0.12.0)
|
||||
- **Reverse Proxy:** (e.g., Nginx, Traefik, Caddy, etc. or None)
|
||||
|
||||
If using Docker and the issue is related to the container, it may be helpful to include your `docker-compose.yml` or relevant variables below.
|
||||
⚠️ Please remember to redact any sensitive information such as passwords or API keys.
|
||||
|
||||
## 📎 Additional Context
|
||||
|
||||
Anything else that might be useful (custom configuration, network setup, database version, etc.)?
|
||||
|
||||
---
|
||||
|
||||
Thank you for taking the time to report this issue! We appreciate your help in making AdventureLog better. We’ll look into it as soon as we can. 🙌
|
||||
113
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
113
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: Bug report
|
||||
description: Found something not working as expected? File a detailed bug report so we can fix it quickly.
|
||||
title: "[BUG] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please search existing issues before filing a new one. This helps avoid duplicates and ensures we can address your concern efficiently.
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of the issue. What went wrong?
|
||||
placeholder: Describe what happened.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Provide exact steps so we can reliably reproduce the issue.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click on ...
|
||||
3. Enter ...
|
||||
4. Observe the error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect AdventureLog to do?
|
||||
placeholder: Describe the expected result.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots-logs
|
||||
attributes:
|
||||
label: Screenshots / Logs
|
||||
description: |
|
||||
Attach screenshots if helpful.
|
||||
Include relevant logs (remove sensitive information).
|
||||
placeholder: Paste logs and add image links here.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Where are you running AdventureLog?
|
||||
options:
|
||||
- Docker
|
||||
- Synology
|
||||
- Proxmox
|
||||
- TrueNAS
|
||||
- Unraid
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
label: Install Method
|
||||
options:
|
||||
- Docker Compose
|
||||
- Quick Install Script
|
||||
- Manual
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: AdventureLog Version
|
||||
description: "Example: v0.12.0"
|
||||
placeholder: v0.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: reverse-proxy
|
||||
attributes:
|
||||
label: Reverse Proxy
|
||||
description: "Example: Nginx, Traefik, Caddy, or None"
|
||||
placeholder: None
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: docker-compose
|
||||
attributes:
|
||||
label: Docker Compose / Relevant Variables (Optional)
|
||||
description: Include docker-compose content or environment variables if relevant. Remove sensitive data.
|
||||
render: yaml
|
||||
placeholder: Paste obfuscated configuration here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any extra information that might help us debug this issue.
|
||||
placeholder: Network setup, custom configuration, database details, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: I searched existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/wRbQ9Egr8C
|
||||
about: For general deployment troubleshooting and community support.
|
||||
25
.github/ISSUE_TEMPLATE/deployment-issue.md
vendored
25
.github/ISSUE_TEMPLATE/deployment-issue.md
vendored
@@ -1,25 +0,0 @@
|
||||
---
|
||||
name: Deployment Issue
|
||||
about: Request help deploying AdventureLog. For faster support, please use our Discord community!
|
||||
title: "[DEPLOYMENT] <short description>"
|
||||
labels: deployment
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
⚠️ **Note:** GitHub issues are primarily for tracking bugs and feature requests.
|
||||
For general deployment troubleshooting and faster community support, please visit our **Discord**: https://discord.gg/wRbQ9Egr8C
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Describe Your Issue
|
||||
|
||||
A clear and concise description of the deployment problem.
|
||||
|
||||
## 🐳 Docker Compose (Obfuscated)
|
||||
|
||||
Please include your `docker-compose.yml` or relevant variable configuration.
|
||||
⚠️ Make sure to **remove or obfuscate sensitive information** (passwords, tokens, keys, etc.).
|
||||
|
||||
```yaml
|
||||
# Example (with secrets removed)
|
||||
```
|
||||
58
.github/ISSUE_TEMPLATE/deployment_issue.yml
vendored
Normal file
58
.github/ISSUE_TEMPLATE/deployment_issue.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Deployment Issue
|
||||
description: Request help deploying AdventureLog.
|
||||
title: "[DEPLOYMENT] "
|
||||
labels:
|
||||
- deployment
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
GitHub issues are primarily for tracking bugs and feature requests.
|
||||
For general deployment troubleshooting and faster community support, visit Discord: https://discord.gg/wRbQ9Egr8C
|
||||
- type: textarea
|
||||
id: deployment-description
|
||||
attributes:
|
||||
label: Describe Your Issue
|
||||
description: A clear and concise description of the deployment problem.
|
||||
placeholder: Explain what is failing and when it happens.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: docker-compose
|
||||
attributes:
|
||||
label: Docker Compose (Obfuscated)
|
||||
description: Include your docker-compose configuration or relevant variables. Remove secrets.
|
||||
render: yaml
|
||||
placeholder: Paste your obfuscated configuration.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Error Output
|
||||
description: Include relevant service logs or command output.
|
||||
placeholder: Paste key logs here.
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: AdventureLog Version
|
||||
description: "In the web app, click the 3-dot menu in the sidebar and select 'About' to find the version number."
|
||||
placeholder: v0.0.0
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: reverse-proxy
|
||||
attributes:
|
||||
label: Reverse Proxy
|
||||
placeholder: Nginx, Traefik, Caddy, None
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: I removed or obfuscated all sensitive data from this issue.
|
||||
required: true
|
||||
39
.github/ISSUE_TEMPLATE/feature_request.md
vendored
39
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a new idea, feature, or improvement for AdventureLog
|
||||
title: "[REQUEST] <short description>"
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
🛑 **Note**: Please search existing issues before creating a new request. This helps avoid duplicates and keeps discussions focused.
|
||||
|
||||
## 💡 Feature Summary
|
||||
|
||||
A clear and concise description of the feature or improvement you’d like to see.
|
||||
|
||||
## 🤔 Problem Statement
|
||||
|
||||
Is your request related to a specific problem?
|
||||
(Example: “I’m always frustrated when…”)
|
||||
|
||||
## 🛠️ Proposed Solution
|
||||
|
||||
Describe the solution you’d like.
|
||||
What should AdventureLog do differently?
|
||||
|
||||
## 🔄 Alternatives Considered
|
||||
|
||||
List any alternative solutions or workarounds you’ve tried or thought about.
|
||||
|
||||
## 📸 Mockups / Examples (optional)
|
||||
|
||||
If you would like, include screenshots, sketches, or links that illustrate the idea.
|
||||
|
||||
## 📎 Additional Context
|
||||
|
||||
Any other details that might help us understand the request (use cases, user stories, related features, etc.).
|
||||
|
||||
---
|
||||
|
||||
✨ Thanks for suggesting improvements to AdventureLog! Your ideas help shape the future of the project.
|
||||
65
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
65
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Feature request
|
||||
description: Suggest a new idea, feature, or improvement for AdventureLog.
|
||||
title: "[REQUEST] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please search existing issues before creating a new request. This helps avoid duplicates and keeps discussions focused.
|
||||
- type: textarea
|
||||
id: feature-summary
|
||||
attributes:
|
||||
label: Feature Summary
|
||||
description: A clear and concise description of the feature or improvement you want.
|
||||
placeholder: Summarize your request.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: Is your request related to a specific problem?
|
||||
placeholder: "Example: I am frustrated when ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you want.
|
||||
placeholder: What should AdventureLog do differently?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: List workarounds or alternative solutions you considered.
|
||||
placeholder: Describe alternatives and why they are not ideal.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: mockups
|
||||
attributes:
|
||||
label: Mockups / Examples (Optional)
|
||||
description: Include screenshots, sketches, or links that illustrate the idea.
|
||||
placeholder: Add links or image references.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other details, use cases, or related features.
|
||||
placeholder: Extra context.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: I searched existing issues and confirmed this request is not a duplicate.
|
||||
required: true
|
||||
44
.github/pull_request_template.md
vendored
Normal file
44
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
## Related Issue
|
||||
|
||||
Closes #
|
||||
|
||||
If this PR does not resolve an issue, please explain why.
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Explain what this PR changes and why.
|
||||
|
||||
---
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Documentation update
|
||||
- [ ] Refactor / code cleanup
|
||||
- [ ] Performance improvement
|
||||
- [ ] Other (please describe)
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before submitting this PR, please confirm:
|
||||
|
||||
- [ ] I have linked an existing issue
|
||||
- [ ] The issue is **approved or marked as ready**
|
||||
- [ ] My changes follow the existing project structure and coding standards
|
||||
- [ ] I have tested the changes locally
|
||||
- [ ] Documentation has been updated if necessary
|
||||
|
||||
---
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
If this PR includes UI changes, please include screenshots or GIFs.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to **AdventureLog**! Your efforts help make this project better for everyone. We’ll review your PR as soon as we can. 🙌
|
||||
85
.github/workflows/adventurelog-bot.yml
vendored
Normal file
85
.github/workflows/adventurelog-bot.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
name: AdventureLog Bot
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
enforce-ready:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Validate linked issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Ignore specific user
|
||||
if (context.actor === "seanmorley15") {
|
||||
console.log("Skipping maintainer PR");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore PRs created before enforcement date
|
||||
const cutoff = new Date("2026-03-16T00:00:00Z");
|
||||
const created = new Date(pr.created_at);
|
||||
|
||||
if (created < cutoff) {
|
||||
console.log("Skipping PR created before enforcement date");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = pr.body || "";
|
||||
const match = body.match(/#(\d+)/);
|
||||
|
||||
if (!match) {
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "🤖 **AdventureLog Bot**\n\n🚫 This PR was automatically closed because it does not reference an issue.\n\nPlease link an issue using `Closes #issue-number`."
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: "closed"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const issueNumber = match[1];
|
||||
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
const labels = issue.labels.map(l => l.name);
|
||||
|
||||
if (!labels.includes("ready")) {
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "🤖 **AdventureLog Bot**\n\n🚫 This PR was automatically closed.\n\nPull requests may only be opened for issues labeled **ready**."
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: "closed"
|
||||
});
|
||||
|
||||
}
|
||||
85
.github/workflows/sync-project-status.yml
vendored
Normal file
85
.github/workflows/sync-project-status.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
name: Sync Project Status
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
update-project:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Update project status from label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
|
||||
const labelMap = {
|
||||
"backlog": "BACKLOG_OPTION_ID",
|
||||
"needs discussion": "DISCUSSION_OPTION_ID",
|
||||
"approved": "APPROVED_OPTION_ID",
|
||||
"ready": "READY_OPTION_ID",
|
||||
"in progress": "IN_PROGRESS_OPTION_ID",
|
||||
"in review": "IN_REVIEW_OPTION_ID",
|
||||
"done": "DONE_OPTION_ID"
|
||||
};
|
||||
|
||||
const label = context.payload.label.name.toLowerCase();
|
||||
const optionId = labelMap[label];
|
||||
|
||||
if (!optionId) return;
|
||||
|
||||
const issueNodeId = context.payload.issue.node_id;
|
||||
|
||||
const projectId = "PVT_kwHOBeIeKs4AfmUO";
|
||||
const fieldId = "PVTSSF_lAHOBeIeKs4AfmUOzgU5pCI";
|
||||
|
||||
// find project item
|
||||
const result = await github.graphql(`
|
||||
query($issueId: ID!) {
|
||||
node(id: $issueId) {
|
||||
... on Issue {
|
||||
projectItems(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {
|
||||
issueId: issueNodeId
|
||||
});
|
||||
|
||||
const itemId = result.node.projectItems.nodes[0]?.id;
|
||||
|
||||
if (!itemId) {
|
||||
console.log("Issue not in project");
|
||||
return;
|
||||
}
|
||||
|
||||
// update status field
|
||||
await github.graphql(`
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { singleSelectOptionId: $optionId }
|
||||
}
|
||||
) {
|
||||
projectV2Item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {
|
||||
projectId: projectId,
|
||||
itemId: itemId,
|
||||
fieldId: fieldId,
|
||||
optionId: optionId
|
||||
});
|
||||
266
CONTRIBUTING.md
266
CONTRIBUTING.md
@@ -1,52 +1,258 @@
|
||||
# Contributing to AdventureLog
|
||||
|
||||
We’re excited to have you contribute to AdventureLog! To ensure that this community remains welcoming and productive for all users and developers, please follow this simple Code of Conduct.
|
||||
Thank you for your interest in contributing to **AdventureLog**!
|
||||
AdventureLog is an open-source project built by and for people who love travel, exploration, and self-hosting. Contributions of all kinds are welcome — whether that’s fixing bugs, improving documentation, suggesting features, or writing code.
|
||||
|
||||
## Pull Request Process
|
||||
Our goal is to keep the project **open, welcoming, and organized** so that contributors can collaborate effectively and the codebase remains maintainable long-term.
|
||||
|
||||
1. **Open an Issue First**: Discuss any changes or features you plan to implement by opening an issue. This helps to clarify your idea and ensures there’s a shared understanding.
|
||||
2. **Document Changes**: If your changes impact the user interface, add new environment variables, or introduce new container configurations, make sure to update the documentation accordingly. The documentation is located in the `documentation` folder.
|
||||
3. **Pull Request**: Submit a pull request with your changes directed towards the `development` branch. Make sure to reference the issue you opened in the description. After your pull request is submitted, it will be reviewed by the maintainers.
|
||||
4. **Review Process**: The maintainers will review your pull request. They may suggest changes or improvements. Please be open to feedback and ready to make adjustments as needed.
|
||||
5. **Merge**: Once your pull request is approved, it will be merged into the `development` branch. This branch is where all new features and changes are integrated before being released to the main branch.
|
||||
This document explains how to contribute and the workflow we use.
|
||||
|
||||
## Code of Conduct
|
||||
---
|
||||
|
||||
### Our Pledge
|
||||
# How Contributions Work
|
||||
|
||||
At AdventureLog, we are committed to creating a community that fosters adventure, exploration, and innovation. We encourage diverse participation and strive to maintain a space where everyone feels welcome to contribute, regardless of their background or experience level. We ask that you contribute with respect and kindness, making sure to prioritize collaboration and mutual growth.
|
||||
AdventureLog uses a structured workflow to keep development organized and to make it easier for contributors to collaborate.
|
||||
|
||||
### Our Standards
|
||||
All development follows this process:
|
||||
|
||||
In order to maintain a positive environment, we encourage the following behaviors:
|
||||
```
|
||||
Issue → Discussion → Approved → Ready → Development → Review → Merge
|
||||
```
|
||||
|
||||
- **Inclusivity**: Use welcoming and inclusive language that fosters collaboration across all perspectives and experiences.
|
||||
- **Respect**: Respect differing opinions and engage with empathy, understanding that each person’s perspective is valuable.
|
||||
- **Constructive Feedback**: Offer feedback that helps improve the project and allows contributors to grow from it.
|
||||
- **Adventure Spirit**: Bring the same sense of curiosity, discovery, and positivity that drives AdventureLog into all interactions with the community.
|
||||
### 1. Open or Find an Issue
|
||||
|
||||
Before starting work, **please open an issue or find an existing one**.
|
||||
|
||||
Issues allow us to:
|
||||
|
||||
- discuss ideas before development begins
|
||||
- coordinate work between contributors
|
||||
- prevent duplicate efforts
|
||||
- maintain a clear roadmap for the project
|
||||
|
||||
If you have an idea for a new feature or improvement, feel free to open an issue describing it.
|
||||
|
||||
---
|
||||
|
||||
### 2. Wait for Approval / Ready Status
|
||||
|
||||
Issues move through several stages:
|
||||
|
||||
**Backlog**
|
||||
An idea or request that has not yet been reviewed.
|
||||
|
||||
**Needs Discussion**
|
||||
The idea requires maintainer feedback or design discussion.
|
||||
|
||||
**Approved**
|
||||
The concept has been accepted but may require planning.
|
||||
|
||||
**Ready**
|
||||
The issue is ready for contributors to begin working on it.
|
||||
|
||||
⚠️ **Pull Requests should only be opened for issues marked `Ready`.**
|
||||
|
||||
This helps ensure contributors work on changes that are aligned with the project’s roadmap.
|
||||
|
||||
---
|
||||
|
||||
### 3. Start Working on the Issue
|
||||
|
||||
Once an issue is marked **Ready**, you can begin working on it.
|
||||
|
||||
If you plan to work on a larger issue, feel free to comment on the issue to let others know.
|
||||
|
||||
This helps prevent duplicate work.
|
||||
|
||||
---
|
||||
|
||||
### 4. Create a Pull Request
|
||||
|
||||
When your changes are ready, open a pull request targeting the **`development` branch**.
|
||||
|
||||
Your pull request must include a reference to the issue it resolves:
|
||||
|
||||
```
|
||||
Closes #issue-number
|
||||
```
|
||||
|
||||
This allows the project automation to track progress and update the project board.
|
||||
|
||||
Example PR description:
|
||||
|
||||
```
|
||||
Closes #123
|
||||
|
||||
Adds support for exporting trips as GPX files.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Review Process
|
||||
|
||||
Once submitted, maintainers will review your pull request.
|
||||
|
||||
Reviews may include:
|
||||
|
||||
- code quality improvements
|
||||
- consistency with the existing architecture
|
||||
- performance considerations
|
||||
- documentation updates
|
||||
|
||||
Please be open to feedback — reviews are intended to **improve the project and help contributors grow**.
|
||||
|
||||
---
|
||||
|
||||
### 6. Merge
|
||||
|
||||
After approval, your pull request will be merged into the **`development` branch**.
|
||||
|
||||
From there, it will eventually be included in the next release.
|
||||
|
||||
Thank you for helping improve AdventureLog!
|
||||
|
||||
---
|
||||
|
||||
# AI / LLM Assistance
|
||||
|
||||
Using AI tools (such as ChatGPT, Copilot, or other LLMs) **is allowed** when contributing to AdventureLog.
|
||||
|
||||
However, contributors are responsible for ensuring that generated code:
|
||||
|
||||
- is **correct and fully understood**
|
||||
- follows the **project’s coding standards**
|
||||
- integrates properly with the existing architecture
|
||||
- does not introduce unnecessary complexity
|
||||
|
||||
AI-generated code that does not meet these standards may be rejected or the pull request may be closed.
|
||||
|
||||
Please review and clean up any AI-generated code before submitting it.
|
||||
|
||||
---
|
||||
|
||||
# Code Quality Expectations
|
||||
|
||||
To keep the project maintainable, all contributions should:
|
||||
|
||||
- follow the existing **code structure and architecture**
|
||||
- use clear and readable code
|
||||
- avoid unnecessary dependencies
|
||||
- include documentation updates when relevant
|
||||
- maintain compatibility with the existing system
|
||||
|
||||
AdventureLog currently includes:
|
||||
|
||||
- **Django** for the backend
|
||||
- **SvelteKit** for the frontend
|
||||
- **Docker-based deployments**
|
||||
|
||||
When contributing, please try to match the **style and patterns already used in the project**.
|
||||
|
||||
---
|
||||
|
||||
# Documentation Changes
|
||||
|
||||
If your changes affect:
|
||||
|
||||
- user workflows
|
||||
- environment variables
|
||||
- deployment setup
|
||||
- API behavior
|
||||
- configuration
|
||||
|
||||
please update the documentation in the:
|
||||
|
||||
```
|
||||
/documentation
|
||||
```
|
||||
|
||||
folder accordingly.
|
||||
|
||||
Keeping documentation accurate is extremely important.
|
||||
|
||||
---
|
||||
|
||||
# Good Issues for New Contributors
|
||||
|
||||
If you are new to the project, look for issues labeled:
|
||||
|
||||
```
|
||||
good first issue
|
||||
help wanted
|
||||
```
|
||||
|
||||
These are great starting points for new contributors.
|
||||
|
||||
---
|
||||
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
At AdventureLog, we are committed to creating a community that fosters adventure, exploration, and innovation.
|
||||
|
||||
We welcome contributors of all experience levels and backgrounds. Everyone should feel comfortable participating and sharing ideas.
|
||||
|
||||
---
|
||||
|
||||
## Our Standards
|
||||
|
||||
To maintain a positive environment, we encourage the following behaviors:
|
||||
|
||||
- **Inclusivity** — Use welcoming and inclusive language.
|
||||
- **Respect** — Respect differing viewpoints and experiences.
|
||||
- **Constructive Feedback** — Provide helpful and actionable feedback.
|
||||
- **Collaboration** — Work together to improve the project.
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- Personal attacks, trolling, or any form of harassment.
|
||||
- Insensitive or discriminatory language, including sexualized comments or imagery.
|
||||
- Spamming or misusing project spaces for personal gain.
|
||||
- Publishing or using others’ private information without permission.
|
||||
- Anything else that could be seen as disrespectful or unprofessional in a collaborative environment.
|
||||
- Personal attacks or harassment
|
||||
- Discriminatory language
|
||||
- Spamming or promotional misuse of project spaces
|
||||
- Sharing private information without consent
|
||||
|
||||
### Our Responsibilities
|
||||
---
|
||||
|
||||
As maintainers of AdventureLog, we are committed to enforcing this Code of Conduct and taking corrective action when necessary. This may involve moderating comments, pulling code, or banning users who engage in harmful behaviors.
|
||||
## Maintainer Responsibilities
|
||||
|
||||
We strive to foster a community that balances open collaboration with respect for all contributors.
|
||||
The AdventureLog maintainers are responsible for enforcing this Code of Conduct and maintaining a respectful community.
|
||||
|
||||
### Scope
|
||||
If necessary, maintainers may:
|
||||
|
||||
This Code of Conduct applies in all spaces related to AdventureLog. This includes our GitHub repository, discussions, documentation, social media accounts, and events—both online and in person.
|
||||
- moderate comments
|
||||
- close pull requests
|
||||
- remove contributions
|
||||
- restrict participation
|
||||
|
||||
### Enforcement
|
||||
These actions will only be taken when necessary to protect the community and the project.
|
||||
|
||||
If you experience or witness unacceptable behavior, please report it to the project team at `contact@adventurelog.app`. All reports will be confidential and handled swiftly. The maintainers will investigate the issue and take appropriate action as needed.
|
||||
---
|
||||
|
||||
### Attribution
|
||||
## Scope
|
||||
|
||||
This Code of Conduct is inspired by the [Contributor Covenant](http://contributor-covenant.org), version 1.4, and adapted to fit the unique spirit of AdventureLog.
|
||||
This Code of Conduct applies to all spaces related to AdventureLog, including:
|
||||
|
||||
- GitHub repositories
|
||||
- GitHub Discussions
|
||||
- documentation
|
||||
- social media
|
||||
- community spaces
|
||||
|
||||
---
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you experience or witness unacceptable behavior, please contact the maintainers at:
|
||||
|
||||
```
|
||||
contact@adventurelog.app
|
||||
```
|
||||
|
||||
All reports will be handled confidentially.
|
||||
|
||||
---
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is inspired by the
|
||||
Contributor Covenant (v1.4) and adapted for the AdventureLog community.
|
||||
|
||||
@@ -3,6 +3,8 @@ import json
|
||||
import zipfile
|
||||
import tempfile
|
||||
import os
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime
|
||||
from django.http import HttpResponse
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -31,6 +33,73 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
Simple ViewSet for handling backup and import operations
|
||||
"""
|
||||
|
||||
def _normalize_money_amount(self, value):
|
||||
"""Return a Decimal amount from legacy or canonical backup values."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# Accept values like "$1,553.59" and "USD 1,553.59".
|
||||
cleaned = re.sub(r'[^0-9,\.\-]', '', text)
|
||||
if not cleaned:
|
||||
return None
|
||||
|
||||
if ',' in cleaned and '.' in cleaned:
|
||||
if cleaned.rfind(',') > cleaned.rfind('.'):
|
||||
cleaned = cleaned.replace('.', '').replace(',', '.')
|
||||
else:
|
||||
cleaned = cleaned.replace(',', '')
|
||||
elif ',' in cleaned:
|
||||
parts = cleaned.split(',')
|
||||
if len(parts) == 2 and len(parts[-1]) in (1, 2):
|
||||
cleaned = cleaned.replace(',', '.')
|
||||
else:
|
||||
cleaned = cleaned.replace(',', '')
|
||||
|
||||
try:
|
||||
return Decimal(cleaned)
|
||||
except InvalidOperation:
|
||||
return None
|
||||
|
||||
def _parse_money(self, value, currency=None, default_currency='USD'):
|
||||
"""Parse a backup money value and return schema-safe (amount, currency)."""
|
||||
parsed_currency = currency
|
||||
parsed_value = value
|
||||
|
||||
if isinstance(value, dict):
|
||||
parsed_value = value.get('amount')
|
||||
parsed_currency = parsed_currency or value.get('currency')
|
||||
|
||||
amount = self._normalize_money_amount(parsed_value)
|
||||
if amount is None:
|
||||
# django-money stores currency in a separate non-null column even when
|
||||
# the monetary amount itself is empty, so imports must preserve a
|
||||
# default currency for blank legacy values.
|
||||
return None, default_currency
|
||||
|
||||
normalized_currency = (parsed_currency or default_currency)
|
||||
if isinstance(normalized_currency, str):
|
||||
normalized_currency = normalized_currency.strip().upper() or default_currency
|
||||
else:
|
||||
normalized_currency = default_currency
|
||||
|
||||
return amount, normalized_currency
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def export(self, request):
|
||||
@@ -106,6 +175,8 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
'tags': location.tags,
|
||||
'description': location.description,
|
||||
'rating': location.rating,
|
||||
'price': str(location.price.amount) if location.price else None,
|
||||
'price_currency': str(location.price.currency) if location.price else None,
|
||||
'link': location.link,
|
||||
'is_public': location.is_public,
|
||||
'longitude': str(location.longitude) if location.longitude else None,
|
||||
@@ -227,6 +298,8 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
'name': transport.name,
|
||||
'description': transport.description,
|
||||
'rating': transport.rating,
|
||||
'price': str(transport.price.amount) if transport.price else None,
|
||||
'price_currency': str(transport.price.currency) if transport.price else None,
|
||||
'link': transport.link,
|
||||
'date': transport.date.isoformat() if transport.date else None,
|
||||
'end_date': transport.end_date.isoformat() if transport.end_date else None,
|
||||
@@ -300,7 +373,8 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
'check_out': lodging.check_out.isoformat() if lodging.check_out else None,
|
||||
'timezone': lodging.timezone,
|
||||
'reservation_number': lodging.reservation_number,
|
||||
'price': str(lodging.price) if lodging.price else None,
|
||||
'price': str(lodging.price.amount) if lodging.price else None,
|
||||
'price_currency': str(lodging.price.currency) if lodging.price else None,
|
||||
'latitude': str(lodging.latitude) if lodging.latitude else None,
|
||||
'longitude': str(lodging.longitude) if lodging.longitude else None,
|
||||
'location': lodging.location,
|
||||
@@ -572,6 +646,11 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
# Import Locations
|
||||
for adv_data in backup_data.get('locations', []):
|
||||
|
||||
location_price, location_price_currency = self._parse_money(
|
||||
adv_data.get('price'),
|
||||
adv_data.get('price_currency')
|
||||
)
|
||||
|
||||
city = None
|
||||
if adv_data.get('city'):
|
||||
try:
|
||||
@@ -600,6 +679,8 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
tags=adv_data.get('tags', []),
|
||||
description=adv_data.get('description'),
|
||||
rating=adv_data.get('rating'),
|
||||
price=location_price,
|
||||
price_currency=location_price_currency,
|
||||
link=adv_data.get('link'),
|
||||
is_public=adv_data.get('is_public', False),
|
||||
longitude=adv_data.get('longitude'),
|
||||
@@ -779,6 +860,11 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
collection = None
|
||||
if trans_data.get('collection_export_id') is not None:
|
||||
collection = collection_map.get(trans_data['collection_export_id'])
|
||||
|
||||
transport_price, transport_price_currency = self._parse_money(
|
||||
trans_data.get('price'),
|
||||
trans_data.get('price_currency')
|
||||
)
|
||||
|
||||
transportation = Transportation.objects.create(
|
||||
user=user,
|
||||
@@ -786,6 +872,8 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
name=trans_data['name'],
|
||||
description=trans_data.get('description'),
|
||||
rating=trans_data.get('rating'),
|
||||
price=transport_price,
|
||||
price_currency=transport_price_currency,
|
||||
link=trans_data.get('link'),
|
||||
date=trans_data.get('date'),
|
||||
end_date=trans_data.get('end_date'),
|
||||
@@ -863,6 +951,11 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
collection = None
|
||||
if lodg_data.get('collection_export_id') is not None:
|
||||
collection = collection_map.get(lodg_data['collection_export_id'])
|
||||
|
||||
lodging_price, lodging_price_currency = self._parse_money(
|
||||
lodg_data.get('price'),
|
||||
lodg_data.get('price_currency')
|
||||
)
|
||||
|
||||
lodging = Lodging.objects.create(
|
||||
user=user,
|
||||
@@ -875,7 +968,8 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
check_out=lodg_data.get('check_out'),
|
||||
timezone=lodg_data.get('timezone'),
|
||||
reservation_number=lodg_data.get('reservation_number'),
|
||||
price=lodg_data.get('price'),
|
||||
price=lodging_price,
|
||||
price_currency=lodging_price_currency,
|
||||
latitude=lodg_data.get('latitude'),
|
||||
longitude=lodg_data.get('longitude'),
|
||||
location=lodg_data.get('location'),
|
||||
|
||||
@@ -29,7 +29,34 @@ class StatsViewSet(viewsets.ViewSet):
|
||||
|
||||
return visited_count
|
||||
|
||||
def _get_activity_stats_by_category(self, user_activities):
|
||||
def _can_view_location(self, request_user, profile_user, location):
|
||||
if not location:
|
||||
return False
|
||||
|
||||
if request_user.is_authenticated and request_user.id == profile_user.id:
|
||||
return True
|
||||
|
||||
return bool(location.is_public)
|
||||
|
||||
def _build_activity_record(self, activity, metric_key, metric_value, profile_user, request_user):
|
||||
if not activity:
|
||||
return None
|
||||
|
||||
location = activity.visit.location if activity.visit_id and activity.visit else None
|
||||
can_view_location = self._can_view_location(request_user, profile_user, location)
|
||||
|
||||
return {
|
||||
'metric_key': metric_key,
|
||||
'metric_value': round(float(metric_value or 0), 2),
|
||||
'activity_id': str(activity.id),
|
||||
'activity_name': activity.name if activity.name else None,
|
||||
'sport_type': activity.sport_type,
|
||||
'start_date': activity.start_date.isoformat() if activity.start_date else None,
|
||||
'location_id': str(location.id) if (location and can_view_location) else None,
|
||||
'location_name': location.name if (location and can_view_location) else None,
|
||||
}
|
||||
|
||||
def _get_activity_stats_by_category(self, user_activities, profile_user, request_user):
|
||||
"""Calculate detailed stats for each sport category"""
|
||||
category_stats = {}
|
||||
|
||||
@@ -87,12 +114,42 @@ class StatsViewSet(viewsets.ViewSet):
|
||||
'avg_speed': round(stats['avg_speed'] or 0, 2),
|
||||
'max_speed': round(stats['max_speed'] or 0, 2),
|
||||
'total_calories': round(stats['total_calories'] or 0, 2),
|
||||
'sports': sport_breakdown
|
||||
'sports': sport_breakdown,
|
||||
'record_holders': {
|
||||
'max_distance': self._build_activity_record(
|
||||
activities.exclude(distance__isnull=True).select_related('visit__location').order_by('-distance', '-start_date').first(),
|
||||
'distance',
|
||||
stats['max_distance'],
|
||||
profile_user,
|
||||
request_user,
|
||||
),
|
||||
'max_speed': self._build_activity_record(
|
||||
activities.exclude(max_speed__isnull=True).select_related('visit__location').order_by('-max_speed', '-start_date').first(),
|
||||
'max_speed',
|
||||
stats['max_speed'],
|
||||
profile_user,
|
||||
request_user,
|
||||
),
|
||||
'max_elevation_gain': self._build_activity_record(
|
||||
activities.exclude(elevation_gain__isnull=True).select_related('visit__location').order_by('-elevation_gain', '-start_date').first(),
|
||||
'elevation_gain',
|
||||
stats['max_elevation_gain'],
|
||||
profile_user,
|
||||
request_user,
|
||||
),
|
||||
'max_calories': self._build_activity_record(
|
||||
activities.exclude(calories__isnull=True).select_related('visit__location').order_by('-calories', '-start_date').first(),
|
||||
'calories',
|
||||
activities.exclude(calories__isnull=True).aggregate(value=Max('calories'))['value'],
|
||||
profile_user,
|
||||
request_user,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return category_stats
|
||||
|
||||
def _get_overall_activity_stats(self, user_activities):
|
||||
def _get_overall_activity_stats(self, user_activities, profile_user, request_user):
|
||||
"""Calculate overall activity statistics"""
|
||||
if not user_activities.exists():
|
||||
return {
|
||||
@@ -101,7 +158,13 @@ class StatsViewSet(viewsets.ViewSet):
|
||||
'total_moving_time': 0,
|
||||
'total_elevation_gain': 0,
|
||||
'total_elevation_loss': 0,
|
||||
'total_calories': 0
|
||||
'total_calories': 0,
|
||||
'record_holders': {
|
||||
'max_distance': None,
|
||||
'max_speed': None,
|
||||
'max_elevation_gain': None,
|
||||
'max_calories': None,
|
||||
},
|
||||
}
|
||||
|
||||
stats = user_activities.aggregate(
|
||||
@@ -124,7 +187,37 @@ class StatsViewSet(viewsets.ViewSet):
|
||||
'total_moving_time': total_moving_seconds,
|
||||
'total_elevation_gain': round(stats['total_elevation_gain'] or 0, 2),
|
||||
'total_elevation_loss': round(stats['total_elevation_loss'] or 0, 2),
|
||||
'total_calories': round(stats['total_calories'] or 0, 2)
|
||||
'total_calories': round(stats['total_calories'] or 0, 2),
|
||||
'record_holders': {
|
||||
'max_distance': self._build_activity_record(
|
||||
user_activities.exclude(distance__isnull=True).select_related('visit__location').order_by('-distance', '-start_date').first(),
|
||||
'distance',
|
||||
user_activities.exclude(distance__isnull=True).aggregate(value=Max('distance'))['value'],
|
||||
profile_user,
|
||||
request_user,
|
||||
),
|
||||
'max_speed': self._build_activity_record(
|
||||
user_activities.exclude(max_speed__isnull=True).select_related('visit__location').order_by('-max_speed', '-start_date').first(),
|
||||
'max_speed',
|
||||
user_activities.exclude(max_speed__isnull=True).aggregate(value=Max('max_speed'))['value'],
|
||||
profile_user,
|
||||
request_user,
|
||||
),
|
||||
'max_elevation_gain': self._build_activity_record(
|
||||
user_activities.exclude(elevation_gain__isnull=True).select_related('visit__location').order_by('-elevation_gain', '-start_date').first(),
|
||||
'elevation_gain',
|
||||
user_activities.exclude(elevation_gain__isnull=True).aggregate(value=Max('elevation_gain'))['value'],
|
||||
profile_user,
|
||||
request_user,
|
||||
),
|
||||
'max_calories': self._build_activity_record(
|
||||
user_activities.exclude(calories__isnull=True).select_related('visit__location').order_by('-calories', '-start_date').first(),
|
||||
'calories',
|
||||
user_activities.exclude(calories__isnull=True).aggregate(value=Max('calories'))['value'],
|
||||
profile_user,
|
||||
request_user,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@action(detail=False, methods=['get'], url_path=r'counts/(?P<username>[\w.@+-]+)')
|
||||
@@ -153,8 +246,8 @@ class StatsViewSet(viewsets.ViewSet):
|
||||
user_activities = Activity.objects.filter(user=user.id)
|
||||
|
||||
# Get enhanced activity statistics
|
||||
overall_activity_stats = self._get_overall_activity_stats(user_activities)
|
||||
activity_stats_by_category = self._get_activity_stats_by_category(user_activities)
|
||||
overall_activity_stats = self._get_overall_activity_stats(user_activities, user, request.user)
|
||||
activity_stats_by_category = self._get_activity_stats_by_category(user_activities, user, request.user)
|
||||
|
||||
return Response({
|
||||
# Travel stats
|
||||
|
||||
@@ -389,9 +389,9 @@ PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
|
||||
ADVENTURELOG_RELEASE_VERSION = 'v0.12.0'
|
||||
|
||||
# https://github.com/dr5hn/countries-states-cities-database/tags
|
||||
COUNTRY_REGION_JSON_VERSION = 'v3.0'
|
||||
COUNTRY_REGION_JSON_VERSION = 'v3.1'
|
||||
|
||||
# External service keys (do not hardcode secrets)
|
||||
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
|
||||
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
|
||||
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')
|
||||
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')
|
||||
|
||||
@@ -42,6 +42,10 @@ class ChangeEmailView(APIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class IsRegistrationDisabled(APIView):
|
||||
# This endpoint is requested on auth pages and should not be globally throttled.
|
||||
# A 429 here can break signup UX even for legitimate users.
|
||||
throttle_classes = []
|
||||
|
||||
@swagger_auto_schema(
|
||||
responses={
|
||||
200: openapi.Response('Registration is disabled'),
|
||||
@@ -112,6 +116,9 @@ class PublicUserDetailView(APIView):
|
||||
|
||||
class UserMetadataView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
# This endpoint is used by the frontend auth hook to hydrate user state.
|
||||
# Global throttling can cause an auth loop and forced logout behavior.
|
||||
throttle_classes = []
|
||||
|
||||
@swagger_auto_schema(
|
||||
responses={
|
||||
|
||||
@@ -5,6 +5,11 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
export const authHook: Handle = async ({ event, resolve }) => {
|
||||
event.cookies.delete('csrftoken', { path: '/' });
|
||||
try {
|
||||
// Image proxy requests can be very high-volume and do not need locals.user.
|
||||
if (event.url.pathname.startsWith('/immich/')) {
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
let sessionid = event.cookies.get('sessionid');
|
||||
|
||||
if (!sessionid) {
|
||||
@@ -23,6 +28,13 @@ export const authHook: Handle = async ({ event, resolve }) => {
|
||||
});
|
||||
|
||||
if (!userFetch.ok) {
|
||||
// Preserve the session on transient backend failures (e.g. 429 throttling)
|
||||
// to avoid forcing users into a logout loop.
|
||||
if (userFetch.status === 429 || userFetch.status >= 500) {
|
||||
event.locals.user = null;
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
event.locals.user = null;
|
||||
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
|
||||
return await resolve(event);
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Category } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let types_arr: string[] = [];
|
||||
export let types: string;
|
||||
let adventure_types: Category[] = [];
|
||||
let isOpen = false;
|
||||
const dispatch = createEventDispatcher<{ change: { types: string } }>();
|
||||
|
||||
onMount(async () => {
|
||||
let categoryFetch = await fetch('/api/categories');
|
||||
let categoryData = await categoryFetch.json();
|
||||
adventure_types = categoryData;
|
||||
console.log(categoryData);
|
||||
types_arr = types.split(',');
|
||||
});
|
||||
|
||||
$: {
|
||||
types_arr = types ? types.split(',').filter((item) => item !== '') : [];
|
||||
}
|
||||
|
||||
function clearTypes() {
|
||||
types = '';
|
||||
types_arr = [];
|
||||
dispatch('change', { types });
|
||||
}
|
||||
|
||||
function toggleSelect(type: string) {
|
||||
@@ -29,42 +35,44 @@
|
||||
types_arr = types_arr.filter((item) => item !== '');
|
||||
// turn types_arr into a comma seperated list with no spaces
|
||||
types = types_arr.join(',');
|
||||
|
||||
console.log(types);
|
||||
console.log(types_arr);
|
||||
dispatch('change', { types });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="collapse collapse-plus mb-4">
|
||||
<input type="checkbox" />
|
||||
|
||||
<div class="collapse-title text-xl bg-base-300 font-medium">
|
||||
<div class="mb-4 rounded-lg bg-base-300">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left text-xl font-medium p-4"
|
||||
on:click={() => (isOpen = !isOpen)}
|
||||
>
|
||||
{$t('adventures.category_filter')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="collapse-content bg-base-300">
|
||||
<button class="btn btn-sm btn-neutral-300 w-full mb-2" on:click={clearTypes}>
|
||||
{$t('adventures.clear')}
|
||||
</button>
|
||||
{#if isOpen}
|
||||
<div class="px-4 pb-4">
|
||||
<button type="button" class="btn btn-sm btn-neutral-300 w-full mb-2" on:click={clearTypes}>
|
||||
{$t('adventures.clear')}
|
||||
</button>
|
||||
|
||||
<ul>
|
||||
{#each adventure_types as type}
|
||||
<li class="mb-1">
|
||||
<label class="cursor-pointer flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
value={type.name}
|
||||
on:change={() => toggleSelect(type.name)}
|
||||
checked={types.indexOf(type.name) > -1}
|
||||
/>
|
||||
<span>
|
||||
{type.display_name}
|
||||
{type.icon} ({type.num_locations})
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<ul>
|
||||
{#each adventure_types as type}
|
||||
<li class="mb-1">
|
||||
<label class="cursor-pointer flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
value={type.name}
|
||||
on:change={() => toggleSelect(type.name)}
|
||||
checked={types_arr.includes(type.name)}
|
||||
/>
|
||||
<span>
|
||||
{type.display_name}
|
||||
{type.icon} ({type.num_locations})
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export let appVersion = 'v0.12.0-main-022726';
|
||||
export let appVersion = 'v0.12.0-main-031526';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.12.0';
|
||||
export let appTitle = 'AdventureLog';
|
||||
export let copyrightYear = '2023-2026';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import LocationCard from '$lib/components/cards/LocationCard.svelte';
|
||||
import CategoryFilterDropdown from '$lib/components/CategoryFilterDropdown.svelte';
|
||||
import CategoryModal from '$lib/components/CategoryModal.svelte';
|
||||
import type { Location } from '$lib/types';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import Plus from '~icons/mdi/plus';
|
||||
@@ -58,17 +59,54 @@
|
||||
let isLocationModalOpen: boolean = false;
|
||||
let sidebarOpen = false;
|
||||
|
||||
// Reactive statements - Only read from URL, don't write
|
||||
$: {
|
||||
if (typeof window !== 'undefined') {
|
||||
let url = new URL(window.location.href);
|
||||
let types = url.searchParams.get('types');
|
||||
if (types) {
|
||||
typeString = types;
|
||||
} else {
|
||||
typeString = '';
|
||||
function syncTypesFromUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = new URL(window.location.href);
|
||||
typeString = url.searchParams.get('types') || '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
syncTypesFromUrl();
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
syncTypesFromUrl();
|
||||
});
|
||||
|
||||
function doApplyFilters() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.getElementById('location-filters-form') as HTMLFormElement | null;
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const url = new URL(window.location.href);
|
||||
url.search = '';
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value !== '') {
|
||||
url.searchParams.append(key, value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (!url.searchParams.get('types')) {
|
||||
url.searchParams.delete('types');
|
||||
}
|
||||
|
||||
url.searchParams.delete('page');
|
||||
goto(url.toString(), { invalidateAll: true, replaceState: true });
|
||||
}
|
||||
|
||||
async function onCategoryChange() {
|
||||
await tick();
|
||||
doApplyFilters();
|
||||
}
|
||||
|
||||
$: {
|
||||
@@ -304,14 +342,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters Form -->
|
||||
<form method="get" class="space-y-6">
|
||||
<form
|
||||
id="location-filters-form"
|
||||
on:submit|preventDefault={doApplyFilters}
|
||||
class="space-y-6"
|
||||
>
|
||||
<input type="hidden" name="types" value={typeString} />
|
||||
<!-- Category Filter -->
|
||||
<div class="card bg-base-200/50 p-4">
|
||||
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Tag class="w-5 h-5" />
|
||||
Categories
|
||||
</h3>
|
||||
<CategoryFilterDropdown bind:types={typeString} />
|
||||
<CategoryFilterDropdown bind:types={typeString} on:change={onCategoryChange} />
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (is_category_modal_open = true)}
|
||||
|
||||
@@ -34,6 +34,17 @@
|
||||
let measurementSystem: string = 'metric';
|
||||
let expandedCategories = new Set();
|
||||
|
||||
type ActivityRecord = {
|
||||
metric_key: string;
|
||||
metric_value: number;
|
||||
activity_id: string;
|
||||
activity_name: string | null;
|
||||
sport_type: string | null;
|
||||
start_date: string | null;
|
||||
location_id: string | null;
|
||||
location_name: string | null;
|
||||
};
|
||||
|
||||
let stats: {
|
||||
visited_country_count: number;
|
||||
total_regions: number;
|
||||
@@ -50,6 +61,12 @@
|
||||
total_elevation_gain: number;
|
||||
total_elevation_loss: number;
|
||||
total_calories: number;
|
||||
record_holders: {
|
||||
max_distance: ActivityRecord | null;
|
||||
max_speed: ActivityRecord | null;
|
||||
max_elevation_gain: ActivityRecord | null;
|
||||
max_calories: ActivityRecord | null;
|
||||
};
|
||||
};
|
||||
activities_by_category: Record<
|
||||
string,
|
||||
@@ -66,6 +83,12 @@
|
||||
avg_speed: number;
|
||||
max_speed: number;
|
||||
total_calories: number;
|
||||
record_holders: {
|
||||
max_distance: ActivityRecord | null;
|
||||
max_speed: ActivityRecord | null;
|
||||
max_elevation_gain: ActivityRecord | null;
|
||||
max_calories: ActivityRecord | null;
|
||||
};
|
||||
sports: Record<
|
||||
string,
|
||||
{
|
||||
@@ -220,15 +243,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getPercentage(value: number, total: number): number {
|
||||
if (!total || total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((value / total) * 100);
|
||||
}
|
||||
|
||||
function getRecordActivityTitle(record: ActivityRecord | null): string {
|
||||
if (!record) {
|
||||
return 'Activity';
|
||||
}
|
||||
|
||||
return record.activity_name || record.sport_type || 'Activity';
|
||||
}
|
||||
|
||||
// Calculate achievements
|
||||
$: worldExplorationPercentage = stats
|
||||
? Math.round((stats.visited_country_count / stats.total_countries) * 100)
|
||||
? getPercentage(stats.visited_country_count, stats.total_countries)
|
||||
: 0;
|
||||
$: regionExplorationPercentage = stats
|
||||
? Math.round((stats.visited_region_count / stats.total_regions) * 100)
|
||||
? getPercentage(stats.visited_region_count, stats.total_regions)
|
||||
: 0;
|
||||
$: cityExplorationPercentage = stats
|
||||
? Math.round((stats.visited_city_count / stats.total_cities) * 100)
|
||||
? getPercentage(stats.visited_city_count, stats.total_cities)
|
||||
: 0;
|
||||
|
||||
// Achievement levels
|
||||
@@ -430,7 +469,7 @@
|
||||
<progress
|
||||
class="progress progress-success w-full h-2"
|
||||
value={stats.visited_country_count}
|
||||
max={stats.total_countries}
|
||||
max={Math.max(stats.total_countries, 1)}
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,7 +503,7 @@
|
||||
<progress
|
||||
class="progress progress-info w-full h-2"
|
||||
value={stats.visited_region_count}
|
||||
max={stats.total_regions}
|
||||
max={Math.max(stats.total_regions, 1)}
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
@@ -494,7 +533,7 @@
|
||||
<progress
|
||||
class="progress progress-warning w-full h-2"
|
||||
value={stats.visited_city_count}
|
||||
max={stats.total_cities}
|
||||
max={Math.max(stats.total_cities, 1)}
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
@@ -515,7 +554,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Overall Activity Summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Total Activities -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-accent/10 to-accent/5 shadow-xl border border-accent/20 hover:shadow-2xl transition-all duration-300"
|
||||
@@ -552,6 +591,21 @@
|
||||
{getDistance(stats.activities_overall.total_distance)}
|
||||
</div>
|
||||
<div class="text-error/60 mt-2">{$t('adventures.distance_covered')}</div>
|
||||
{#if stats.activities_overall.record_holders?.max_distance}
|
||||
{@const maxDistanceRecord =
|
||||
stats.activities_overall.record_holders.max_distance}
|
||||
<div class="mt-2 text-xs text-error/70">
|
||||
Longest single activity: {getRecordActivityTitle(maxDistanceRecord)}
|
||||
</div>
|
||||
{#if maxDistanceRecord?.location_id}
|
||||
<a
|
||||
href={`/locations/${maxDistanceRecord.location_id}`}
|
||||
class="link link-error text-xs"
|
||||
>
|
||||
Happened at {maxDistanceRecord.location_name || 'this location'}
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-3 bg-error/20 rounded-2xl">
|
||||
<TrendingUpOutline class="w-6 h-6 text-error" />
|
||||
@@ -596,6 +650,21 @@
|
||||
{getElevation(stats.activities_overall.total_elevation_gain)}
|
||||
</div>
|
||||
<div class="text-orange-500/60 mt-2">{$t('adventures.total_climbed')}</div>
|
||||
{#if stats.activities_overall.record_holders?.max_elevation_gain}
|
||||
{@const maxGainRecord =
|
||||
stats.activities_overall.record_holders.max_elevation_gain}
|
||||
<div class="mt-2 text-xs text-orange-500/70">
|
||||
Biggest climb: {getRecordActivityTitle(maxGainRecord)}
|
||||
</div>
|
||||
{#if maxGainRecord?.location_id}
|
||||
<a
|
||||
href={`/locations/${maxGainRecord.location_id}`}
|
||||
class="link text-xs text-orange-500"
|
||||
>
|
||||
Happened at {maxGainRecord.location_name || 'this location'}
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-3 bg-orange-500/20 rounded-2xl">
|
||||
<Mountain class="w-6 h-6 text-orange-500" />
|
||||
@@ -603,6 +672,67 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Elevation Loss -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-info/10 to-info/5 shadow-xl border border-info/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-info/70 font-medium text-sm uppercase tracking-wide">
|
||||
{$t('adventures.elevation_loss')}
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-info">
|
||||
{getElevation(stats.activities_overall.total_elevation_loss)}
|
||||
</div>
|
||||
<div class="text-info/60 mt-2">Descent recorded</div>
|
||||
</div>
|
||||
<div class="p-3 bg-info/20 rounded-2xl">
|
||||
<Mountain class="w-6 h-6 text-info rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stats.activities_overall.total_calories > 0}
|
||||
<!-- Calories -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-warning/10 to-warning/5 shadow-xl border border-warning/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-warning/70 font-medium text-sm uppercase tracking-wide">
|
||||
{$t('adventures.calories')}
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-warning">
|
||||
{Math.round(stats.activities_overall.total_calories)}
|
||||
</div>
|
||||
<div class="text-warning/60 mt-2">Energy burned</div>
|
||||
{#if stats.activities_overall.record_holders?.max_calories}
|
||||
{@const maxCaloriesRecord =
|
||||
stats.activities_overall.record_holders.max_calories}
|
||||
<div class="mt-2 text-xs text-warning/70">
|
||||
Most calories in: {getRecordActivityTitle(maxCaloriesRecord)}
|
||||
</div>
|
||||
{#if maxCaloriesRecord?.location_id}
|
||||
<a
|
||||
href={`/locations/${maxCaloriesRecord.location_id}`}
|
||||
class="link link-warning text-xs"
|
||||
>
|
||||
Happened at {maxCaloriesRecord.location_name || 'this location'}
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-3 bg-warning/20 rounded-2xl">
|
||||
<Fire class="w-6 h-6 text-warning" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Activity Categories -->
|
||||
@@ -653,8 +783,9 @@
|
||||
{getDistance(categoryData.total_distance)}
|
||||
</div>
|
||||
<div class="text-{config.color}/60 text-sm">
|
||||
{getElevation(categoryData.total_elevation_gain)}
|
||||
{$t('adventures.elevation')}
|
||||
Max {getSpeed(categoryData.max_speed)}
|
||||
<span class="mx-1">•</span>
|
||||
{getElevation(categoryData.total_elevation_gain)} gain
|
||||
</div>
|
||||
</div>
|
||||
<svelte:component
|
||||
@@ -668,7 +799,7 @@
|
||||
{#if isExpanded}
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Quick Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div
|
||||
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
|
||||
>
|
||||
@@ -689,6 +820,36 @@
|
||||
{getSpeed(categoryData.avg_speed)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
|
||||
>
|
||||
<div class="text-{config.color}/70 text-xs uppercase font-medium">
|
||||
Max Speed
|
||||
</div>
|
||||
<div class="text-lg font-bold text-{config.color}">
|
||||
{getSpeed(categoryData.max_speed)}
|
||||
</div>
|
||||
{#if categoryData.record_holders?.max_speed?.location_id}
|
||||
<a
|
||||
href={`/locations/${categoryData.record_holders.max_speed.location_id}`}
|
||||
class="link text-xs text-{config.color}"
|
||||
>
|
||||
{$t('locations.best_happened_at')}
|
||||
{categoryData.record_holders.max_speed.location_name ||
|
||||
'this location'}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
|
||||
>
|
||||
<div class="text-{config.color}/70 text-xs uppercase font-medium">
|
||||
Avg Distance
|
||||
</div>
|
||||
<div class="text-lg font-bold text-{config.color}">
|
||||
{getDistance(categoryData.avg_distance)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
|
||||
>
|
||||
@@ -698,6 +859,54 @@
|
||||
<div class="text-lg font-bold text-{config.color}">
|
||||
{getDistance(categoryData.max_distance)}
|
||||
</div>
|
||||
{#if categoryData.record_holders?.max_distance?.location_id}
|
||||
<a
|
||||
href={`/locations/${categoryData.record_holders.max_distance.location_id}`}
|
||||
class="link text-xs text-{config.color}"
|
||||
>
|
||||
Best happened at {categoryData.record_holders.max_distance
|
||||
.location_name || 'this location'}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
|
||||
>
|
||||
<div class="text-{config.color}/70 text-xs uppercase font-medium">
|
||||
Avg Gain
|
||||
</div>
|
||||
<div class="text-lg font-bold text-{config.color}">
|
||||
{getElevation(categoryData.avg_elevation_gain)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
|
||||
>
|
||||
<div class="text-{config.color}/70 text-xs uppercase font-medium">
|
||||
Max Gain
|
||||
</div>
|
||||
<div class="text-lg font-bold text-{config.color}">
|
||||
{getElevation(categoryData.max_elevation_gain)}
|
||||
</div>
|
||||
{#if categoryData.record_holders?.max_elevation_gain?.location_id}
|
||||
<a
|
||||
href={`/locations/${categoryData.record_holders.max_elevation_gain.location_id}`}
|
||||
class="link text-xs text-{config.color}"
|
||||
>
|
||||
Best happened at {categoryData.record_holders.max_elevation_gain
|
||||
.location_name || 'this location'}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
|
||||
>
|
||||
<div class="text-{config.color}/70 text-xs uppercase font-medium">
|
||||
{$t('adventures.elevation_loss')}
|
||||
</div>
|
||||
<div class="text-lg font-bold text-{config.color}">
|
||||
{getElevation(categoryData.total_elevation_loss)}
|
||||
</div>
|
||||
</div>
|
||||
{#if categoryData.total_calories > 0}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user