Deployment-ready: All services configured
20
.gitignore
vendored
|
|
@ -1,11 +1,13 @@
|
||||||
portfolio/mongo/data/
|
# Docker data
|
||||||
portfolio/mongo/db_backups/
|
mysql-*
|
||||||
portfolio/mongo/auth/
|
session-ses_*.md
|
||||||
portfolio/src/node_modules/
|
forgejo/
|
||||||
|
redis/
|
||||||
|
nextcloud/
|
||||||
|
|
||||||
|
# Environment & secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Nginx
|
||||||
nginx/certs/
|
nginx/certs/
|
||||||
nginx/conf.d/default.conf
|
nginx/conf.d/default.conf
|
||||||
nextcloud/
|
|
||||||
gitea/
|
|
||||||
.env
|
|
||||||
portfolio-nuxt/.nuxt/
|
|
||||||
portfolio-nuxt/node_modules/
|
|
||||||
|
|
|
||||||
158
AGENTS.md
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
# Agent Guidelines for Unboundedpress Dev
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This repository contains two main projects:
|
||||||
|
- **`portfolio-nuxt/`** - Primary Nuxt 3 application (Vue 3, TypeScript, Tailwind CSS, Pinia)
|
||||||
|
- **`portfolio/`** - Legacy Express.js application (plain JavaScript)
|
||||||
|
|
||||||
|
The Nuxt project is the main focus for development.
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
### portfolio-nuxt (Primary Project)
|
||||||
|
```bash
|
||||||
|
cd portfolio-nuxt
|
||||||
|
npm install # Install dependencies
|
||||||
|
npm run dev # Development server
|
||||||
|
npm run build # Build for production
|
||||||
|
npm run generate # Generate static site
|
||||||
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### portfolio (Legacy Project)
|
||||||
|
```bash
|
||||||
|
cd portfolio/src
|
||||||
|
npm run serve # Development with nodemon
|
||||||
|
npm run format # Format code
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running a Single Test
|
||||||
|
**No test framework is currently configured.** If you add tests:
|
||||||
|
- For Nuxt: Use Vitest
|
||||||
|
- Run a single test: `npx vitest run --testNamePattern="test name"`
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### General Principles
|
||||||
|
- Use TypeScript in Vue components (`.vue` with `<script setup lang="ts">`)
|
||||||
|
- Use JavaScript (`.js`) in Pinia stores under `stores/`
|
||||||
|
- Follow Vue 3 Composition API with `<script setup>`
|
||||||
|
- Use Tailwind CSS classes for styling
|
||||||
|
|
||||||
|
### TypeScript Conventions
|
||||||
|
- Use `defineProps` and `defineEmits` with type-based syntax
|
||||||
|
- Use `withDefaults` for optional props with default values
|
||||||
|
- Prefer `ref` and `reactive` from Vue
|
||||||
|
- Use `toRefs` when destructuring props
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
persistent?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: false,
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue Component Structure
|
||||||
|
1. `<script setup lang="ts">` - Logic (imports, props, emits, composables)
|
||||||
|
2. `<template>` - Template with slots
|
||||||
|
3. `<style>` - Scoped styles (if needed, Tailwind preferred)
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- Use `@/` alias for imports (configured in Nuxt)
|
||||||
|
- Import Vue core: `import { ref, watch } from 'vue'`
|
||||||
|
- Import from stores: `import { useModalStore } from '@/stores/ModalStore'`
|
||||||
|
- Import icons: `import Icon from 'nuxt-icon'` or `<Icon name="..." />` in template
|
||||||
|
|
||||||
|
### File Naming
|
||||||
|
- Vue components: PascalCase (e.g., `IconButton.vue`, `Modal.vue`)
|
||||||
|
- Component folders: PascalCase with index.vue (e.g., `components/Modal/`)
|
||||||
|
- Stores: PascalCase with Store suffix (e.g., `AudioPlayerStore.js`, `ModalStore.js`)
|
||||||
|
- Pages: kebab-case (e.g., `index.vue`, `about-us.vue`)
|
||||||
|
|
||||||
|
### Tailwind CSS
|
||||||
|
- Use utility classes for all styling
|
||||||
|
- Follow existing patterns in components (see `components/Modal/Modal.vue`)
|
||||||
|
- Use `bg-`, `text-`, `p-`, `m-`, `flex-`, `grid-` prefixes
|
||||||
|
|
||||||
|
### State Management (Pinia)
|
||||||
|
- Create stores in `stores/` directory
|
||||||
|
- Use JavaScript (`.js`) for stores
|
||||||
|
- Follow the pattern in `stores/ModalStore.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
|
||||||
|
export const useModalStore = defineStore("ModalStore", {
|
||||||
|
state: () => ({ key: "value" }),
|
||||||
|
actions: {
|
||||||
|
setKey(value) {
|
||||||
|
this.key = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Data Fetching
|
||||||
|
- Use Nuxt's `useFetch` and `useAsyncData` composables
|
||||||
|
- Transform data in the `transform` option
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data: works } = await useFetch('https://unboundedpress.org/api/works', {
|
||||||
|
transform: (works) => {
|
||||||
|
// transformation logic
|
||||||
|
return transformedData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Handle null/undefined values in templates with `v-if` guards
|
||||||
|
- Use optional chaining (`?.`) when accessing nested properties
|
||||||
|
- Validate URLs before using them (see pattern in `pages/index.vue`)
|
||||||
|
|
||||||
|
### Head Management
|
||||||
|
Use `useHead` for SEO metadata:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Site Title - %s'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transition and Animation
|
||||||
|
- Use Vue's built-in `<Transition>` and `<TransitionRoot>` components
|
||||||
|
- Use Headless UI for complex components (already installed)
|
||||||
|
|
||||||
|
## Additional Conventions
|
||||||
|
|
||||||
|
### Composables
|
||||||
|
- Place reusable logic in `composables/` directory
|
||||||
|
- Use the `use` prefix (e.g., `useModal`)
|
||||||
|
|
||||||
|
### Server Routes
|
||||||
|
- Place API routes in `server/api/` directory
|
||||||
|
- Use `.ts` files for TypeScript routes
|
||||||
|
|
||||||
|
### Image Handling
|
||||||
|
- Use `<NuxtImg>` component for optimized images
|
||||||
|
- Configure domains in `nuxt.config.ts` under `image.domains`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
- Use `.env` files for local development
|
||||||
|
- Template available in `.env_template`
|
||||||
|
- Never commit secrets to version control
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
- `Dockerfile` provided for deployment
|
||||||
|
- Multi-stage build available in `Dockerfile_upgrade`
|
||||||
294
README.md
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
# Unboundedpress
|
||||||
|
|
||||||
|
Self-hosted web infrastructure using Docker Compose.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **portfolio**: ⚠️ **MOST IMPORTANT** - Main Nuxt 3 website containing a majority of my life's work (replaced old Express.js portfolio)
|
||||||
|
- **Nextcloud**: File storage and document editing
|
||||||
|
- **Forgejo**: Code repository (migrated from Gitea)
|
||||||
|
- **Collabora**: Online document editor (integrated with Nextcloud)
|
||||||
|
- **nginx-proxy**: Reverse proxy with automatic HTTPS (Let's Encrypt)
|
||||||
|
- **Redis**: Caching for Nextcloud
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose installed
|
||||||
|
- Ports 80 and 443 available
|
||||||
|
- Domain DNS pointing to server
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone repository
|
||||||
|
git clone <repo-url>
|
||||||
|
cd unboundedpress_dev
|
||||||
|
|
||||||
|
# 2. Create .env file
|
||||||
|
cp .env_template .env
|
||||||
|
# Edit .env with your values
|
||||||
|
|
||||||
|
# 3. Start services
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. Verify
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables (.env)
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| DOMAIN | Your domain | unboundedpress.org |
|
||||||
|
| USER | Admin username | mwinter |
|
||||||
|
| PASSWORD | Admin password | ************ |
|
||||||
|
| EMAIL | Email for SSL certificates | admin@example.com |
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | URL | Description |
|
||||||
|
|---------|-----|-------------|
|
||||||
|
| portfolio | https://{domain}/ | Main website |
|
||||||
|
| Nextcloud | https://{domain}/cloud/ | File storage & documents |
|
||||||
|
| Forgejo | https://{domain}/code/ | Git repositories |
|
||||||
|
| Collabora | https://{domain}/collab/ | Document editing (integrated with Nextcloud) |
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Step 1: Update Environment
|
||||||
|
|
||||||
|
Edit `.env`:
|
||||||
|
```bash
|
||||||
|
DOMAIN=unboundedpress.org
|
||||||
|
# Comment out or remove: HTTPS_METHOD=noredirect
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Update Nextcloud Collabora URL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nextcloud occ config:app:set richdocuments public_wopi_url --value="https://unboundedpress.org/collab"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Restart Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify SSL
|
||||||
|
|
||||||
|
SSL certificates are automatically issued by acme-companion. Check status:
|
||||||
|
```bash
|
||||||
|
docker logs nginx-proxy-acme
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### HTTPS Setup (mkcert)
|
||||||
|
|
||||||
|
For local development with HTTPS, use mkcert to create locally-trusted certificates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install mkcert (Arch Linux)
|
||||||
|
sudo pacman -S mkcert
|
||||||
|
|
||||||
|
# Install local CA
|
||||||
|
mkcert -install
|
||||||
|
|
||||||
|
# Create certificates
|
||||||
|
cd nginx/certs
|
||||||
|
mkcert -key-file key.pem -cert-file cert.pem "localdev.unboundedpress.org" "*.localdev.unboundedpress.org"
|
||||||
|
|
||||||
|
# Rename to default certificate
|
||||||
|
mv cert.pem default.crt
|
||||||
|
mv key.pem default.key
|
||||||
|
|
||||||
|
# Restart proxy
|
||||||
|
docker compose restart nginx-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Local Services
|
||||||
|
|
||||||
|
After setup, access at:
|
||||||
|
- Main site: https://localdev.unboundedpress.org/
|
||||||
|
- Nextcloud: https://localdev.unboundedpress.org/cloud/
|
||||||
|
- Forgejo: https://localdev.unboundedpress.org/code/
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Bot Blocker Updates
|
||||||
|
|
||||||
|
The nginx-ultimate-bad-bot-blocker updates automatically via cron (monthly on the 1st at 3 AM).
|
||||||
|
|
||||||
|
Manual update:
|
||||||
|
```bash
|
||||||
|
docker exec nginx-proxy update-ngxblocker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup Nextcloud
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database backup
|
||||||
|
docker exec mysql-nextcloud mysqldump -u root -p${PASSWORD} nextcloud > backup_nextcloud_db_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Files backup (run on host)
|
||||||
|
tar -czf nextcloud_backup_$(date +%Y%m%d).tar.gz nextcloud/html/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup Forgejo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database backup
|
||||||
|
docker exec mysql-forgejo mysqldump -u root -p${PASSWORD} forgejo > backup_forgejo_db_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Files backup (run on host)
|
||||||
|
tar -czf forgejo_backup_$(date +%Y%m%d).tar.gz forgejo/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest images
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# Restart services with new images
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker compose logs -f nginx-proxy
|
||||||
|
docker compose logs -f nextcloud
|
||||||
|
docker compose logs -f forgejo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
⚠️ **Important**: The `portfolio/` directory contains the majority of my life's work. Ensure backups are current before making any changes.
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
.
|
||||||
|
├── docker-compose.yml # Main compose file
|
||||||
|
├── .env # Environment variables (not in repo)
|
||||||
|
├── .env_template # Template for .env
|
||||||
|
├── nginx/
|
||||||
|
│ ├── Dockerfile # nginx-proxy build with bot blocker
|
||||||
|
│ ├── certs/ # SSL certificates
|
||||||
|
│ ├── conf.d/ # nginx configuration
|
||||||
|
│ ├── vhost.d/ # Virtual host configs
|
||||||
|
│ ├── bots.d/ # Bot blocker rules
|
||||||
|
│ └── crontab # Cron for bot blocker updates
|
||||||
|
├── portfolio/
|
||||||
|
│ ├── Dockerfile # Multi-stage production build
|
||||||
|
│ └── ...
|
||||||
|
├── nextcloud/
|
||||||
|
│ ├── html/ # Nextcloud data
|
||||||
|
│ └── mysql/ # Nextcloud database
|
||||||
|
├── forgejo/
|
||||||
|
│ └── ... # Forgejo data
|
||||||
|
└── redis/
|
||||||
|
└── ... # Redis data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Nextcloud Login Issues
|
||||||
|
|
||||||
|
If login redirects back to login page:
|
||||||
|
1. Clear browser cookies
|
||||||
|
2. Check trusted_domains in config
|
||||||
|
3. Ensure HTTPS is properly configured
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check trusted domains
|
||||||
|
docker exec nextcloud occ config:system:get trusted_domains
|
||||||
|
|
||||||
|
# Add domain if needed
|
||||||
|
docker exec nextcloud occ config:system:set trusted_domains 4 --value="unboundedpress.org"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collabora Not Opening Documents
|
||||||
|
|
||||||
|
1. Verify public_wopi_url is set correctly:
|
||||||
|
```bash
|
||||||
|
docker exec nextcloud occ config:app:get richdocuments public_wopi_url
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check nginx config for /collab/ routing:
|
||||||
|
```bash
|
||||||
|
docker exec nginx-proxy cat /etc/nginx/vhost.d/unboundedpress.org | grep -A 5 "location /collab"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check Collabora logs:
|
||||||
|
```bash
|
||||||
|
docker logs collabora
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL Certificate Issues
|
||||||
|
|
||||||
|
1. Check acme-companion logs:
|
||||||
|
```bash
|
||||||
|
docker logs nginx-proxy-acme
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify ports 80/443 are open:
|
||||||
|
```bash
|
||||||
|
sudo ufw status
|
||||||
|
# or
|
||||||
|
sudo iptables -L -n
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check certificate files exist:
|
||||||
|
```bash
|
||||||
|
ls -la nginx/certs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
|
||||||
|
1. Check logs for errors:
|
||||||
|
```bash
|
||||||
|
docker compose logs [service-name]
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify .env file exists and has correct values
|
||||||
|
|
||||||
|
3. Check port conflicts:
|
||||||
|
```bash
|
||||||
|
sudo netstat -tlnp | grep ':80\|:443'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nginx-proxy (port 80/443)
|
||||||
|
│
|
||||||
|
├── portfolio ─────► :5000
|
||||||
|
│
|
||||||
|
├── nextcloud ──────────► :80 → /cloud/
|
||||||
|
│ ├── mysql-nextcloud
|
||||||
|
│ └── redis
|
||||||
|
│
|
||||||
|
├── forgejo ────────────► :4000 → /code/
|
||||||
|
│ └── mysql-forgejo
|
||||||
|
│
|
||||||
|
└── collabora ──────────► :9980 → /collab/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy)
|
||||||
|
- [acme-companion](https://github.com/nginx-proxy/acme-companion)
|
||||||
|
- [nginx-ultimate-bad-bot-blocker](https://github.com/mitchellkrogza/nginx-ultimate-bad-bot-blocker)
|
||||||
|
- [Collabora](https://www.collaboraoffice.com/)
|
||||||
|
- [Forgejo](https://forgejo.org/)
|
||||||
|
- [Nextcloud](https://nextcloud.com/)
|
||||||
|
- [Redis](https://redis.io/)
|
||||||
42
README.txt
|
|
@ -1,42 +0,0 @@
|
||||||
# unboundedpress
|
|
||||||
|
|
||||||
Repo for my personal website.
|
|
||||||
|
|
||||||
If you have the keys to my castle, you should be able to archive the entire top level folder, move everything to another server and just deploy with:
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
But that is being pretty optimistic.
|
|
||||||
|
|
||||||
In the docker-compose.yml file, there are detailed notes of non-automatic steps that need to be taken, especially for deploying from scratch.
|
|
||||||
|
|
||||||
Here are some useful tips.
|
|
||||||
|
|
||||||
The current server is running on ubuntu server 22.04/
|
|
||||||
|
|
||||||
# Running on docker. Here are some install tips.
|
|
||||||
# https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-22-04
|
|
||||||
# https://www.digitalocean.com/community/tutorials/how-to-install-docker-compose-on-ubuntu-22-04
|
|
||||||
|
|
||||||
# There are two .env files that are the same and go at the top level folder and again in portfolio/src. Templates are provided.
|
|
||||||
|
|
||||||
# Here is a list of the services
|
|
||||||
# Main components
|
|
||||||
porfolio - node server and frontend for my porfilio that uses mongo backend
|
|
||||||
mongo - houses the data of my portfolio
|
|
||||||
restheart - api to feed data from mongo to portfolio (though there are some end points built into the node app)
|
|
||||||
gitea - my code repository
|
|
||||||
mysql-gitea - databse for gitea
|
|
||||||
nginx-proxy - reverse proxy for everything
|
|
||||||
acme-companion - lets encrypt certificate manager
|
|
||||||
|
|
||||||
# Extra components
|
|
||||||
nextcloud
|
|
||||||
mysql-nextcloud
|
|
||||||
|
|
||||||
# Here are some useful tips for mongo
|
|
||||||
# dump and restore example:
|
|
||||||
# this is done through the container: docker exec it mongo sh
|
|
||||||
mongodump --host localhost --port 27017 -d portfolio -o /db_backups/db_dump_2025_02_04 -u username -p password --authenticationDatabase admin
|
|
||||||
mongorestore --host localhost --port 27017 -d portfolio -u username -p password --authenticationDatabase admin /db_backups/db_dump_2025_02_04/portfolio
|
|
||||||
# here is an example of a file upload through restheart:
|
|
||||||
curl -v -u user:pass -X POST -F 'properties={"filename":"filename"}' -F "file=@filepath_here" https://unboundedpress.org/api/scores.files
|
|
||||||
|
|
@ -1,34 +1,25 @@
|
||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
nginx-proxy:
|
nginx-proxy:
|
||||||
build: ./nginx
|
build: ./nginx
|
||||||
# TODO: Note that this is built with ultimate-bad-bot-blocker scripts
|
|
||||||
# that currently need to be run manually to update
|
|
||||||
# (with the possibility that the bots.d folder has to be blown away first - not sure)
|
|
||||||
# Eventually, this needs to be checked and put on a chron job
|
|
||||||
# docker exec -t nginx-proxy bash
|
|
||||||
# /usr/local/sbin/setup-ngxblocker -x
|
|
||||||
# /usr/local/sbin/update-ngxblocker -x email
|
|
||||||
container_name: nginx-proxy
|
container_name: nginx-proxy
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
restart: always
|
restart: always
|
||||||
#environment:
|
#environment:
|
||||||
# - HTTPS_METHOD=noredirect
|
# - HTTPS_METHOD=noredirect
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/conf.d:/etc/nginx/conf.d
|
- ./nginx/conf.d:/etc/nginx/conf.d
|
||||||
- ./nginx/vhost.d:/etc/nginx/vhost.d
|
- ./nginx/vhost.d:/etc/nginx/vhost.d
|
||||||
- ./nginx/bots.d:/etc/nginx/bots.d
|
- ./nginx/bots.d:/etc/nginx/bots.d
|
||||||
- ./nginx/certs:/etc/nginx/certs:rw
|
- ./nginx/certs:/etc/nginx/certs:rw
|
||||||
- nginx:/usr/share/nginx/html
|
- nginx:/usr/share/nginx/html
|
||||||
#- nginx:/app/nginx.tmpl
|
|
||||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
#- ./nginx/htpasswd:/etc/nginx/htpasswd
|
- ./nginx/crontab:/etc/crontabs/root:ro
|
||||||
|
|
||||||
acme-companion:
|
acme-companion:
|
||||||
image: nginxproxy/acme-companion:2.2
|
image: nginxproxy/acme-companion:2.6.2
|
||||||
container_name: nginx-proxy-acme
|
container_name: nginx-proxy-acme
|
||||||
environment:
|
environment:
|
||||||
- DEFAULT_EMAIL=${EMAIL}
|
- DEFAULT_EMAIL=${EMAIL}
|
||||||
|
|
@ -48,208 +39,41 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
portfolio:
|
portfolio:
|
||||||
# TODO: This will eventually be rewritten with something like VUE
|
|
||||||
container_name: portfolio
|
container_name: portfolio
|
||||||
build: ./portfolio
|
build:
|
||||||
# To just server running the following command
|
context: ./portfolio
|
||||||
#command: bash -c "npm run serve"
|
args:
|
||||||
# To reinstall the packages run the following command instead
|
- PASSWORD=${PASSWORD}
|
||||||
command: bash -c "npm install && npm run serve"
|
|
||||||
volumes:
|
volumes:
|
||||||
- portfolio:/src/node_modules
|
- portfolio:/src/node_modules
|
||||||
- ./portfolio/src:/src
|
- ./portfolio/server/data:/src/server/data
|
||||||
environment:
|
|
||||||
- VIRTUAL_HOST=${DOMAIN},www.${DOMAIN}
|
|
||||||
#- VIRTUAL_PATH=/
|
|
||||||
# For subdirectory baseURL needs to be set in app.js for static files and routes
|
|
||||||
- VIRTUAL_PATH=/legacy
|
|
||||||
- VIRTUAL_DEST=/legacy
|
|
||||||
- VIRTUAL_PORT=3000
|
|
||||||
#- LETSENCRYPT_HOST=${DOMAIN},www.${DOMAIN},gitea.${DOMAIN} #this last one is for legacy support
|
|
||||||
#- LETSENCRYPT_EMAIL=${EMAIL}
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
mongo:
|
|
||||||
condition: service_healthy
|
|
||||||
#restheart:
|
|
||||||
#nginx-proxy:
|
|
||||||
#labels:
|
|
||||||
# com.github.nginx-proxy.nginx-proxy.keepalive: "64"
|
|
||||||
|
|
||||||
portfolio-nuxt:
|
|
||||||
# NOTE: This is the rewrite of the frontend
|
|
||||||
# NOTE: The build process for nuxt seems to require that sharp be reinstalled in the .output folder
|
|
||||||
container_name: portfolio-nuxt
|
|
||||||
build: ./portfolio-nuxt
|
|
||||||
# To rebuild the site and the server run this
|
|
||||||
command: bash -c "npm run build && node .output/server/index.mjs"
|
|
||||||
# To just start the server run this
|
|
||||||
#command: bash -c "node .output/server/index.mjs"
|
|
||||||
# To start the server in dev mode
|
|
||||||
#command: bash -c "npm run dev -o"
|
|
||||||
volumes:
|
|
||||||
- portfolio-nuxt:/src/node_modules
|
|
||||||
- ./portfolio-nuxt:/src
|
|
||||||
environment:
|
environment:
|
||||||
|
- PASSWORD=${PASSWORD}
|
||||||
- VIRTUAL_HOST=${DOMAIN},www.${DOMAIN}
|
- VIRTUAL_HOST=${DOMAIN},www.${DOMAIN}
|
||||||
- VIRTUAL_PATH=/
|
- VIRTUAL_PATH=/
|
||||||
#- VIRTUAL_DEST=/dev
|
|
||||||
# For subdirectory baseURL needs to be set in nuxt config
|
|
||||||
#- VIRTUAL_PATH=/dev
|
|
||||||
#- VIRTUAL_DEST=/dev
|
|
||||||
- VIRTUAL_PORT=5000
|
- VIRTUAL_PORT=5000
|
||||||
- LETSENCRYPT_HOST=${DOMAIN},www.${DOMAIN},gitea.${DOMAIN} #this last one is for legacy support
|
- LETSENCRYPT_HOST=${DOMAIN},www.${DOMAIN},gitea.${DOMAIN}
|
||||||
- LETSENCRYPT_EMAIL=${EMAIL}
|
- LETSENCRYPT_EMAIL=${EMAIL}
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- restheart
|
nginx-proxy:
|
||||||
- nginx-proxy
|
|
||||||
#labels:
|
|
||||||
# com.github.nginx-proxy.nginx-proxy.keepalive: "64"
|
|
||||||
|
|
||||||
mongo:
|
|
||||||
container_name: mongo
|
|
||||||
# using mongo4 or mongo5 as opposed to mongo:6 for server status in mongo-express and because of bugs
|
|
||||||
# mongo 5 requires avx support so if the machine is not capable of avx support use mongo4
|
|
||||||
# NOTE: mongo 4 shell uses mongo and mongo 5 uses mongosh!
|
|
||||||
# These need to be changed accordingly in the health check and the mongosetup.sh file for the container mongo-init
|
|
||||||
image: mongo:5
|
|
||||||
#image: mongo:4
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
- MONGO_INITDB_ROOT_USERNAME=${USER}
|
|
||||||
- MONGO_INITDB_ROOT_PASSWORD=${PASSWORD}
|
|
||||||
- MONGO_INITDB_DATABASE=portfolio
|
|
||||||
command: ["--keyFile", "/auth/keyfile", "--replSet", "rs0", "--bind_ip_all"]
|
|
||||||
# NOTE: If starting from scracth, create key for mongo then put it in ./mongo/auth/
|
|
||||||
# openssl rand -base64 756 > keyfile
|
|
||||||
# chmod 600 keyfile
|
|
||||||
# sudo chown 999 keyfile
|
|
||||||
# sudo chgrp 999 keyfile
|
|
||||||
# NOTE: If you tar archive the site and move it without retaining permissions,
|
|
||||||
# you will need to run the last 3 lines on the file to make it work
|
|
||||||
ports:
|
|
||||||
- 27017:27017
|
|
||||||
volumes:
|
|
||||||
- ./portfolio/mongo/data/db:/data/db
|
|
||||||
- ./portfolio/mongo/data/configdb:/data/configdb
|
|
||||||
- ./portfolio/mongo/auth/keyfile:/auth/keyfile
|
|
||||||
- ./portfolio/mongo/db_backups:/db_backups
|
|
||||||
healthcheck:
|
|
||||||
# mongo 5
|
|
||||||
test: echo 'rs.status().ok' | mongosh --host mongo:27017 -u $${MONGO_INITDB_ROOT_USERNAME} -p $${MONGO_INITDB_ROOT_PASSWORD} --quiet | grep 1
|
|
||||||
# mongo 4
|
|
||||||
#test: echo 'rs.status().ok' | mongo --host mongo:27017 -u $${MONGO_INITDB_ROOT_USERNAME} -p $${MONGO_INITDB_ROOT_PASSWORD} --quiet | grep 1
|
|
||||||
interval: 15s
|
|
||||||
start_period: 20s
|
|
||||||
|
|
||||||
mongo-init:
|
|
||||||
container_name: mongo-init
|
|
||||||
# using mongo5 as opposed to mongo:6 for server status in mongo-express and because of bugs
|
|
||||||
image: mongo:5
|
|
||||||
restart: on-failure
|
|
||||||
volumes:
|
|
||||||
# mongo 5
|
|
||||||
- ./portfolio/mongo/scripts/mongo5setup.sh:/scripts/mongo5setup.sh
|
|
||||||
# mongo 4
|
|
||||||
#- ./portfolio/mongo/scripts/mongo4setup.sh:/scripts/mongo4setup.sh
|
|
||||||
# these two are necessary otherwise they get created again as anonymous volumes
|
|
||||||
- ./portfolio/mongo/data/db:/data/db
|
|
||||||
- ./portfolio/mongo/data/configdb:/data/configdb
|
|
||||||
# mongo 5
|
|
||||||
entrypoint: ["bash", "/scripts/mongo5setup.sh" ]
|
|
||||||
# mongo 4
|
|
||||||
#entrypoint: ["bash", "/scripts/mongo4setup.sh" ]
|
|
||||||
environment:
|
|
||||||
- MONGO_INITDB_ROOT_USERNAME=${USER}
|
|
||||||
- MONGO_INITDB_ROOT_PASSWORD=${PASSWORD}
|
|
||||||
depends_on:
|
|
||||||
mongo:
|
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
mongo-express:
|
|
||||||
# using mongo-express:0.54 as opposed to mongo-express:1 for server status and because of bugs
|
|
||||||
image: mongo-express:0.54
|
|
||||||
container_name: mongo-express
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
- ME_CONFIG_MONGODB_URL=mongodb://${USER}:${PASSWORD}@mongo:27017/?replicaSet=rs0
|
|
||||||
- ME_CONFIG_MONGODB_ADMINUSERNAME=${USER}
|
|
||||||
- ME_CONFIG_MONGODB_ADMINPASSWORD=${PASSWORD}
|
|
||||||
- ME_CONFIG_MONGODB_ENABLE_ADMIN=true
|
|
||||||
- ME_CONFIG_BASICAUTH_USERNAME=${USER}
|
|
||||||
- ME_CONFIG_BASICAUTH_PASSWORD=${PASSWORD}
|
|
||||||
- ME_CONFIG_SITE_BASEURL=/admin
|
|
||||||
- ME_CONFIG_SITE_GRIDFS_ENABLED=true
|
|
||||||
- VIRTUAL_HOST=${DOMAIN},admin.${DOMAIN}
|
|
||||||
- VIRTUAL_PATH=/admin/
|
|
||||||
- VIRTUAL_PORT=8081
|
|
||||||
#volumes:
|
|
||||||
# - ./nginx/certs:/etc/nginx/certs:ro
|
|
||||||
depends_on:
|
|
||||||
mongo:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "8081:8081"
|
|
||||||
|
|
||||||
restheart:
|
forgejo:
|
||||||
image: softinstigate/restheart:7
|
image: codeberg.org/forgejo/forgejo:7
|
||||||
container_name: restheart
|
container_name: forgejo
|
||||||
# NOTE: the api_admin endpoint only works locally
|
|
||||||
environment:
|
|
||||||
- RHO=
|
|
||||||
/mongo/mongo-mounts[1]->{'where':'/api','what':'portfolio'};
|
|
||||||
/mongo/mongo-mounts[2]->{'where':'/api_admin','what':'restheart'};
|
|
||||||
/mclient/connection-string->'mongodb://${USER}:${PASSWORD}@mongo:27017/?replicaSet=rs0';
|
|
||||||
/http-listener/host->'0.0.0.0';
|
|
||||||
# NOTE: If starting from scratch use must set admin password!
|
|
||||||
# curl -u admin:secret -X PATCH localhost:8080/api_admin/users/admin -H "Content-Type: application/json" -d '{ "password": "my-strong-password" }'
|
|
||||||
# NOTE: An ACL entry to allow unaunthenticated users to perform gets must be added
|
|
||||||
# For now, it was added to the restheart db manually
|
|
||||||
# by adding the following to the acl collection with curl or using mongo-express
|
|
||||||
# {
|
|
||||||
# predicate: 'path-prefix[/api] and method[GET]',
|
|
||||||
# roles: ['$unauthenticated'],
|
|
||||||
# priority: 50
|
|
||||||
# }
|
|
||||||
# This does not seem to do anything but should somehow use a file for the realm creations
|
|
||||||
#/fileRealmAuthenticator/users[userid='admin']/password->'${PASSWORD}';
|
|
||||||
- VIRTUAL_HOST=${DOMAIN},www.${DOMAIN}
|
|
||||||
- VIRTUAL_PATH=/api/
|
|
||||||
- VIRTUAL_DEST=/api/
|
|
||||||
- VIRTUAL_PORT=8080
|
|
||||||
depends_on:
|
|
||||||
mongo:
|
|
||||||
condition: service_healthy
|
|
||||||
#command: ["--envFile", "/opt/restheart/etc/default.properties"]
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
restart: always
|
|
||||||
#volumes:
|
|
||||||
# - ./restheart:/opt/restheart/etc:ro
|
|
||||||
|
|
||||||
gitea:
|
|
||||||
image: gitea/gitea:1
|
|
||||||
container_name: gitea
|
|
||||||
environment:
|
environment:
|
||||||
- USER_UID=1000
|
- USER_UID=1000
|
||||||
- USER_GID=1000
|
- USER_GID=1000
|
||||||
- GITEA__database__DB_TYPE=mysql
|
- FORGEJO__database__DB_TYPE=mysql
|
||||||
- GITEA__database__HOST=mysql-gitea
|
- FORGEJO__database__HOST=mysql-forgejo
|
||||||
- GITEA__database__NAME=gitea
|
- FORGEJO__database__NAME=forgejo
|
||||||
- GITEA__database__USER=${USER}
|
- FORGEJO__database__USER=${USER}
|
||||||
- GITEA__database__PASSWD=${PASSWORD}
|
- FORGEJO__database__PASSWD=${PASSWORD}
|
||||||
- GITEA__server__LANDING_PAGE=/${USER}
|
- FORGEJO__server__LANDING_PAGE=/${USER}
|
||||||
- GITEA__attachment__MAX_SIZE=5000
|
- FORGEJO__attachment__MAX_SIZE=5000
|
||||||
#- GITEA__repository.upload__FILE_MAX_SIZE=5000
|
- FORGEJO__server__ROOT_URL=https://${DOMAIN}/code/
|
||||||
# NOTE: This next line can be commented out if you want to run the wizard locally
|
|
||||||
# But it needs to be set properly as the base url to work remotely
|
|
||||||
# no matter how you run the wizard
|
|
||||||
- GITEA__server__ROOT_URL=https://${DOMAIN}/code/
|
|
||||||
- HTTP_PORT=4000
|
- HTTP_PORT=4000
|
||||||
- LFS_START_SERVER=true
|
- LFS_START_SERVER=true
|
||||||
- DISABLE_REGISTRATION=true
|
- DISABLE_REGISTRATION=true
|
||||||
|
|
@ -260,19 +84,16 @@ services:
|
||||||
- VIRTUAL_DEST=/
|
- VIRTUAL_DEST=/
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./gitea:/data
|
- ./forgejo:/data
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
ports:
|
|
||||||
- "4000:4000"
|
|
||||||
- "222:22"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql-gitea:
|
mysql-forgejo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
mysql-gitea:
|
mysql-forgejo:
|
||||||
image: mariadb:10
|
image: mariadb:10.11
|
||||||
container_name: mysql-gitea
|
container_name: mysql-forgejo
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_ROOT_PASSWORD=${PASSWORD}
|
- MYSQL_ROOT_PASSWORD=${PASSWORD}
|
||||||
|
|
@ -280,21 +101,17 @@ services:
|
||||||
- MYSQL_DATABASE=gitea
|
- MYSQL_DATABASE=gitea
|
||||||
- MYSQL_USER=${USER}
|
- MYSQL_USER=${USER}
|
||||||
volumes:
|
volumes:
|
||||||
- ./gitea/mysql:/var/lib/mysql
|
- ./forgejo/mysql:/var/lib/mysql
|
||||||
#- ./mysql_gitea/etc:/etc/mysql/conf.d
|
|
||||||
#- ./mysql_gitea/init:/docker-entrypoint-initdb.d
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
nextcloud:
|
nextcloud:
|
||||||
image: nextcloud:25
|
image: nextcloud:31-apache
|
||||||
container_name: nextcloud
|
container_name: nextcloud
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
#- ./nextcloud/data:/var/www/html/data
|
|
||||||
#- nextcloud:/var/www/html
|
|
||||||
- ./nextcloud/html:/var/www/html
|
- ./nextcloud/html:/var/www/html
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_DATABASE=nextcloud
|
- MYSQL_DATABASE=nextcloud
|
||||||
|
|
@ -304,34 +121,31 @@ services:
|
||||||
- NEXTCLOUD_ADMIN_USER=${USER}
|
- NEXTCLOUD_ADMIN_USER=${USER}
|
||||||
- NEXTCLOUD_ADMIN_PASSWORD=${PASSWORD}
|
- NEXTCLOUD_ADMIN_PASSWORD=${PASSWORD}
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=${DOMAIN} www.${DOMAIN}
|
- NEXTCLOUD_TRUSTED_DOMAINS=${DOMAIN} www.${DOMAIN}
|
||||||
#- NEXTCLOUD_INIT_LOCK=true
|
- NEXTCLOUD_EXTRA_APPS=calendar,richdocuments
|
||||||
- APACHE_DISABLE_REWRITE_IP=1
|
- APACHE_DISABLE_REWRITE_IP=1
|
||||||
- TRUSTED_PROXIES=nginx-proxy
|
- TRUSTED_PROXIES=nginx-proxy
|
||||||
|
- REDIS_HOST=redis
|
||||||
- OVERWRITEHOST=${DOMAIN}
|
- OVERWRITEHOST=${DOMAIN}
|
||||||
- OVERWRITEWEBROOT=/cloud
|
- OVERWRITEWEBROOT=/cloud
|
||||||
- OVERWRITEPROTOCOL=https
|
- OVERWRITEPROTOCOL=https
|
||||||
#- OVERWRITECLIURL=http://localhost/
|
|
||||||
- OVERWRITECLIURL=https://unboundedpress.org
|
- OVERWRITECLIURL=https://unboundedpress.org
|
||||||
# NOTE: These configurations above make it work with the subdirectory
|
|
||||||
# but you cannot set VIRTUAL_PORT
|
|
||||||
# for reasons I have no idea
|
|
||||||
- VIRTUAL_HOST=${DOMAIN},www.${DOMAIN}
|
- VIRTUAL_HOST=${DOMAIN},www.${DOMAIN}
|
||||||
- VIRTUAL_PATH=/cloud/
|
- VIRTUAL_PATH=/cloud/
|
||||||
- VIRTUAL_DEST=/
|
- VIRTUAL_DEST=/
|
||||||
# TODO: add redis and chron
|
|
||||||
#- REDIS_HOST=redis
|
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql-nextcloud:
|
mysql-nextcloud:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
#redis:
|
redis:
|
||||||
ports:
|
condition: service_started
|
||||||
- 8888:80
|
|
||||||
|
|
||||||
collabora:
|
collabora:
|
||||||
image: collabora/code:22.05.14.3.1
|
image: collabora/code:latest
|
||||||
container_name: collabora
|
container_name: collabora
|
||||||
depends_on:
|
depends_on:
|
||||||
- nextcloud
|
nginx-proxy:
|
||||||
|
condition: service_started
|
||||||
|
nextcloud:
|
||||||
|
condition: service_started
|
||||||
cap_add:
|
cap_add:
|
||||||
- MKNOD
|
- MKNOD
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -347,23 +161,25 @@ services:
|
||||||
- extra_params=--o:ssl.enable=false --o:ssl.termination=true
|
- extra_params=--o:ssl.enable=false --o:ssl.termination=true
|
||||||
# NOTE: The file nginx/vhosts.d/unboundedpress.org handles
|
# NOTE: The file nginx/vhosts.d/unboundedpress.org handles
|
||||||
# routing for collabora on production only
|
# routing for collabora on production only
|
||||||
ports:
|
|
||||||
- 9980:9980
|
|
||||||
|
|
||||||
cron-nextcloud:
|
cron-nextcloud:
|
||||||
image: nextcloud:25
|
image: nextcloud:31-apache
|
||||||
container_name: cron-nextcloud
|
container_name: cron-nextcloud
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./nextcloud/html:/var/www/html
|
- ./nextcloud/html:/var/www/html
|
||||||
entrypoint: /cron.sh
|
entrypoint: /cron.sh
|
||||||
|
environment:
|
||||||
|
- NEXTCLOUD_EXTRA_APPS=calendar,richdocuments
|
||||||
|
- REDIS_HOST=redis
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql-nextcloud:
|
mysql-nextcloud:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
#redis:
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
mysql-nextcloud:
|
mysql-nextcloud:
|
||||||
image: mariadb:10
|
image: mariadb:10.11
|
||||||
container_name: mysql-nextcloud
|
container_name: mysql-nextcloud
|
||||||
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
|
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
|
||||||
restart: always
|
restart: always
|
||||||
|
|
@ -379,9 +195,19 @@ services:
|
||||||
interval: 15s
|
interval: 15s
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
container_name: redis
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./redis:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nginx:
|
nginx:
|
||||||
#nextcloud:
|
|
||||||
acme:
|
acme:
|
||||||
portfolio:
|
portfolio:
|
||||||
portfolio-nuxt:
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
FROM nginxproxy/nginx-proxy:1.2
|
FROM nginxproxy/nginx-proxy:1.9.0
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y wget cron && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install curl
|
# Install curl
|
||||||
# RUN apk add --no-cache curl
|
# RUN apk add --no-cache curl
|
||||||
|
|
|
||||||
12
nginx/bots.d/blacklist-domains.conf
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# DEPRECATED
|
||||||
|
|
||||||
|
# This file is no longer used
|
||||||
|
|
||||||
|
# ********************************************
|
||||||
|
# ALL Blacklisting AND Whitelisting is done in
|
||||||
|
# ********************************************
|
||||||
|
|
||||||
|
# include /etc/nginx/bots.d/whitelist-domains.conf; < whitelisting of domains (can also blacklist)
|
||||||
|
# include /etc/nginx/bots.d/custom-bad-referrers.conf; < whitelisting AND blacklisting of domains and referrer domains
|
||||||
|
|
||||||
|
# DEPRECATED
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
|
|
||||||
# VERSION INFORMATION #
|
# VERSION INFORMATION #
|
||||||
#----------------------
|
#----------------------
|
||||||
# Version: V4.2022.03
|
# Version: V4.2024.01
|
||||||
# Updated: 2022-03-25
|
# Updated: 2024-04-23
|
||||||
#----------------------
|
#----------------------
|
||||||
# VERSION INFORMATION #
|
# VERSION INFORMATION #
|
||||||
|
|
||||||
|
|
@ -53,12 +53,27 @@
|
||||||
# "~*(?:\b)someverygooduseragentname2(?:\b)" 0;
|
# "~*(?:\b)someverygooduseragentname2(?:\b)" 0;
|
||||||
# "~*(?:\b)some\-very\-good\-useragentname2(?:\b)" 0;
|
# "~*(?:\b)some\-very\-good\-useragentname2(?:\b)" 0;
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# RATE LIMITING EXAMPLES
|
||||||
|
# ----------------------
|
||||||
|
# "~*(?:\b)someverybaduseragentname1(?:\b)" 2;
|
||||||
|
# "~*(?:\b)someverybaduseragentname2(?:\b)" 2;
|
||||||
|
# "~*(?:\b)some\-very\-bad\-useragentname3(?:\b)" 2;
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# BLACKLISTING EXAMPLES
|
# BLACKLISTING EXAMPLES
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# "~*(?:\b)someverybaduseragentname1(?:\b)" 3;
|
# "~*(?:\b)someverybaduseragentname4(?:\b)" 3;
|
||||||
# "~*(?:\b)someverybaduseragentname2(?:\b)" 3;
|
# "~*(?:\b)someverybaduseragentname5(?:\b)" 3;
|
||||||
# "~*(?:\b)some\-very\-bad\-useragentname2(?:\b)" 3;
|
# "~*(?:\b)some\-very\-bad\-useragentname6(?:\b)" 3;
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# SUPER RATE LIMITING EXAMPLES
|
||||||
|
# ----------------------------
|
||||||
|
# "~*(?:\b)someverybaduseragentname7(?:\b)" 4;
|
||||||
|
# "~*(?:\b)someverybaduseragentname8(?:\b)" 4;
|
||||||
|
# "~*(?:\b)some\-very\-bad\-useragentname9(?:\b)" 4;
|
||||||
|
|
||||||
|
|
||||||
# Here are some default things I block on my own server, these appear in various types of injection attacks
|
# Here are some default things I block on my own server, these appear in various types of injection attacks
|
||||||
# You can disable them if you have problems or don't agree by switching thir value to 0 or moving them into the whitelist section first and then making their value 0
|
# You can disable them if you have problems or don't agree by switching thir value to 0 or moving them into the whitelist section first and then making their value 0
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
# VERSION INFORMATION #
|
# VERSION INFORMATION #
|
||||||
#----------------------
|
#----------------------
|
||||||
# Version: V4.2019.04
|
# Version: V5.2024.04
|
||||||
# Updated: 2019-06-28
|
# Updated: 2024-04-30
|
||||||
#----------------------
|
#----------------------
|
||||||
# VERSION INFORMATION #
|
# VERSION INFORMATION #
|
||||||
|
|
||||||
|
|
@ -49,15 +49,19 @@
|
||||||
# BLOCK BAD BOTS
|
# BLOCK BAD BOTS
|
||||||
# --------------
|
# --------------
|
||||||
|
|
||||||
# Section bot_1 Unused
|
|
||||||
#limit_conn bot1_connlimit 100;
|
#limit_conn bot1_connlimit 100;
|
||||||
#limit_req zone=bot1_reqlimitip burst=50;
|
#limit_req zone=bot1_reqlimitip burst=50;
|
||||||
|
|
||||||
limit_conn bot2_connlimit 10;
|
limit_conn bot2_connlimit 10;
|
||||||
limit_req zone=bot2_reqlimitip burst=10;
|
limit_req zone=bot2_reqlimitip burst=10;
|
||||||
|
|
||||||
|
# Uncomment below lines for super rate limiting feature
|
||||||
|
#limit_conn bot4_connlimit 10;
|
||||||
|
#limit_req zone=bot4_reqlimitip burst=10;
|
||||||
|
|
||||||
if ($bad_bot = '3') {
|
if ($bad_bot = '3') {
|
||||||
return 444; # << Response Code Issued May Be Modified to Whatever you Choose ie. 404 but 444 wastes less of Nginxs time
|
return 444;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# BLOCK BAD REFER WORDS
|
# BLOCK BAD REFER WORDS
|
||||||
|
|
|
||||||
1
nginx/crontab
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0 3 1 * * /usr/local/sbin/update-ngxblocker
|
||||||
|
|
@ -1,12 +1,2 @@
|
||||||
## Start of configuration add by letsencrypt container
|
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
|
||||||
auth_basic off;
|
|
||||||
auth_request off;
|
|
||||||
allow all;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
try_files $uri =404;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
## End of configuration add by letsencrypt container
|
|
||||||
include /etc/nginx/bots.d/ddos.conf;
|
include /etc/nginx/bots.d/ddos.conf;
|
||||||
include /etc/nginx/bots.d/blockbots.conf;
|
include /etc/nginx/bots.d/blockbots.conf;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,3 @@
|
||||||
## Start of configuration add by letsencrypt container
|
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
|
||||||
auth_basic off;
|
|
||||||
auth_request off;
|
|
||||||
allow all;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
try_files $uri =404;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
## End of configuration add by letsencrypt container
|
|
||||||
# This is needed for legacy support
|
# This is needed for legacy support
|
||||||
location = / {
|
location = / {
|
||||||
rewrite ^ http://gitea.unboundedpress.org/code/mwinter/ redirect;
|
rewrite ^ http://gitea.unboundedpress.org/code/mwinter/ redirect;
|
||||||
|
|
|
||||||
43
nginx/vhost.d/localdev.unboundedpress.org
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Collabora routing for localdev.unboundedpress.org
|
||||||
|
|
||||||
|
# static files
|
||||||
|
location ^~ /browser {
|
||||||
|
proxy_pass http://localdev.unboundedpress.org-cd15914db06db1d6722abd3bcfd0beaa541bbc60;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WOPI discovery URL
|
||||||
|
location ^~ /hosting/discovery {
|
||||||
|
proxy_pass http://localdev.unboundedpress.org-cd15914db06db1d6722abd3bcfd0beaa541bbc60;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capabilities
|
||||||
|
location ^~ /hosting/capabilities {
|
||||||
|
proxy_pass http://localdev.unboundedpress.org-cd15914db06db1d6722abd3bcfd0beaa541bbc60;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# main websocket
|
||||||
|
location ~ ^/cool/(.*)/ws$ {
|
||||||
|
proxy_pass http://localdev.unboundedpress.org-cd15914db06db1d6722abd3bcfd0beaa541bbc60;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_read_timeout 36000s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# download, presentation and image upload
|
||||||
|
location ~ ^/(c|l)ool {
|
||||||
|
proxy_pass http://localdev.unboundedpress.org-cd15914db06db1d6722abd3bcfd0beaa541bbc60;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin Console websocket
|
||||||
|
location ^~ /cool/adminws {
|
||||||
|
proxy_pass http://localdev.unboundedpress.org-cd15914db06db1d6722abd3bcfd0beaa541bbc60;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_read_timeout 36000s;
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,8 @@
|
||||||
## Start of configuration add by letsencrypt container
|
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
|
||||||
auth_basic off;
|
|
||||||
auth_request off;
|
|
||||||
allow all;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
try_files $uri =404;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
## End of configuration add by letsencrypt container
|
|
||||||
|
|
||||||
# This is to set the content type to prevent downloading until I actually implement a proper pdf viewer
|
# Allow HTTP for local development (DISABLED - now using HTTPS)
|
||||||
|
#if ($host = 'localdev.unboundedpress.org') {
|
||||||
location ~ ^/api/(scores|pubs)(.*)/binary$ {
|
# set $do_not_redirect 1;
|
||||||
proxy_pass http://unboundedpress.org-357322ae39f93f572e23cd9edd3307e2ac5a321f;
|
#}
|
||||||
# types { } default_type application/pdf;
|
|
||||||
proxy_hide_header Content-Type;
|
|
||||||
add_header Content-Type "application/pdf";
|
|
||||||
}
|
|
||||||
|
|
||||||
# The following are all for collabora routing
|
# The following are all for collabora routing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
FROM node:18-alpine
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN apk add bash
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
ENV NITRO_HOST=0.0.0.0
|
|
||||||
ENV NITRO_PORT=5000
|
|
||||||
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
# ENTRYPOINT ["npm", "run", "build", "node", ".output/server/index.mjs"]
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="inline-flex p-1 min-w-[25px]">
|
|
||||||
<div v-show="visible" class="bg-black rounded-full text-xs inline-flex" >
|
|
||||||
|
|
||||||
<NuxtLink @click.native="audioPlayerStore.setSoundCloudTrackID(work.soundcloud_trackid)" v-if="type === 'score'" class="inline-flex p-1" :to="link">
|
|
||||||
<Icon name="ion:book-sharp" color="white" />
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<NuxtLink v-else-if="type === 'document'" class="inline-flex p-1" :to="link">
|
|
||||||
<Icon name="ion:book-sharp" color="white" />
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<NuxtLink v-else-if="type === 'buy'" class="inline-flex p-1" :to="link">
|
|
||||||
<Icon name="bxs:purchase-tag" color="white" />
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<NuxtLink v-else-if="type === 'email'" class="inline-flex p-1" :to="link">
|
|
||||||
<Icon name="ic:baseline-email" color="white" />
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<NuxtLink v-else-if="type === 'discogs'" class="inline-flex p-1" :to="link">
|
|
||||||
<Icon name="simple-icons:discogs" color="white" />
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<button @click="audioPlayerStore.setSoundCloudTrackID(work.soundcloud_trackid)" v-else-if="type === 'audio'" class="inline-flex p-1">
|
|
||||||
<Icon name="wpf:speaker" color="white" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button @click="modalStore.setModalProps('video', 'aspect-video', true, '', '', work.vimeo_trackid)" v-else-if="type === 'video'" class="inline-flex p-1">
|
|
||||||
<Icon name="fluent:video-48-filled" color="white" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'images', work.gallery, '')" v-else="type === 'image'" class="inline-flex p-1">
|
|
||||||
<Icon name="mdi:camera" color="white" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
|
||||||
import { useModalStore } from "@/stores/ModalStore"
|
|
||||||
|
|
||||||
const audioPlayerStore = useAudioPlayerStore()
|
|
||||||
const modalStore = useModalStore()
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: ['type', 'work', 'visible', 'link']
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="grid grid-cols-[63%,35%] w-full font-thin sticky top-0 bg-white p-2 z-20">
|
|
||||||
<div>
|
|
||||||
<div class="text-5xl p-2"> <NuxtLink to='/'>michael winter</NuxtLink></div>
|
|
||||||
<div class="inline-flex text-2xl ml-4">
|
|
||||||
<NuxtLink class="px-3" to='/'>works</NuxtLink>
|
|
||||||
<NuxtLink class="px-3" to='/events'>events</NuxtLink>
|
|
||||||
<NuxtLink class="px-3" to='/about'>about</NuxtLink>
|
|
||||||
<NuxtLink class="px-3" to='https://unboundedpress.org/code'>code</NuxtLink>
|
|
||||||
<NuxtLink class="px-3 block" to='https://unboundedpress.org/legacy'>legacy</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- hdp link while active -->
|
|
||||||
<!------
|
|
||||||
<div class="inline-flex text-2xl ml-4 font-bold">
|
|
||||||
<NuxtLink class="px-3" to='/a_history_of_the_domino_problem'>A HISTORY OF THE DOMINO PROBLEM | 17.11 - 01.12.2023 </NuxtLink>
|
|
||||||
</div>
|
|
||||||
--->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TODO: this needs to be automatically flipped off when there are no upcoming events-->
|
|
||||||
<!------
|
|
||||||
<div class="px-1 bg-zinc-100 rounded-lg text-center">
|
|
||||||
<div class="text-sm">upcoming events</div>
|
|
||||||
<EventSlider :upcoming_events="upcoming_events" class="max-w-[95%] min-h-[80%]"></EventSlider>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<slot /> <!-- required here only -->
|
|
||||||
<div class="fixed bottom-0 bg-white p-2 w-full flex justify-center z-20">
|
|
||||||
<iframe width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay" v-if="audioPlayerStore.soundcloud_trackid !== 'undefined'"
|
|
||||||
:src="'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + audioPlayerStore.soundcloud_trackid + '&inverse=false&auto_play=true&show_user=false'"></iframe>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal v-model="modalStore.isOpen">
|
|
||||||
<ModalBody :class="modalStore.aspect">
|
|
||||||
<ImageSlider v-if="modalStore.type === 'image'" :bucket="modalStore.bucket" :gallery="modalStore.gallery"></ImageSlider>
|
|
||||||
<iframe v-if="modalStore.type === 'video'" :src="'https://player.vimeo.com/video/' + modalStore.vimeo_trackid" width="100%" height="100%" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>
|
|
||||||
</ModalBody>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
|
||||||
import { useModalStore } from "@/stores/ModalStore"
|
|
||||||
|
|
||||||
const audioPlayerStore = useAudioPlayerStore()
|
|
||||||
const modalStore = useModalStore()
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
if(route.params.files == 'scores') {
|
|
||||||
const { data: work } = await useFetch('https://unboundedpress.org/api/works?filter={"score":"' + route.params.filename + '"}', {
|
|
||||||
transform: (work) => {
|
|
||||||
|
|
||||||
if(work[0].soundcloud_trackid){
|
|
||||||
audioPlayerStore.setSoundCloudTrackID(work[0].soundcloud_trackid)
|
|
||||||
} else {
|
|
||||||
audioPlayerStore.clearSoundCloudTrackID()
|
|
||||||
}
|
|
||||||
return work[0]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
//const today = Date.now(); //annoying this does not work
|
|
||||||
const today = 1715247305793;
|
|
||||||
const { data: upcoming_events } = await useFetch("https://unboundedpress.org/api/events?filter={'start_date':{'$gte':{'$date':" + today + "}}}", {
|
|
||||||
transform: (upcoming_events) => {
|
|
||||||
for (const event of upcoming_events) {
|
|
||||||
let date = new Date(event.start_date.$date)
|
|
||||||
event.formatted_date = ("0" + (date.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
|
||||||
}
|
|
||||||
return upcoming_events.sort((a,b) => a.start_date.$date - b.start_date.$date)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
17705
portfolio-nuxt/package-lock.json
generated
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"name": "nuxt-app",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"build": "nuxt build",
|
|
||||||
"dev": "nuxt dev",
|
|
||||||
"generate": "nuxt generate",
|
|
||||||
"preview": "nuxt preview",
|
|
||||||
"postinstall": "nuxt prepare"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@nuxt/image": "^1.0.0-rc.1",
|
|
||||||
"@nuxtjs/tailwindcss": "^6.7.0",
|
|
||||||
"@types/node": "^18",
|
|
||||||
"nuxt-headlessui": "^1.1.4",
|
|
||||||
"nuxt-icon": "^0.4.1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"nuxt": "^3.6.0",
|
|
||||||
"@pinia/nuxt": "^0.4.11",
|
|
||||||
"nuxt-swiper": "^1.1.0",
|
|
||||||
"nuxt-umami": "^2.4.2",
|
|
||||||
"pinia": "^2.1.3",
|
|
||||||
"sharp": "^0.32.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="flex min-h-full items-center justify-center text-center">
|
|
||||||
<embed v-if="route.params.filename.split('.').pop()==='pdf'" :src="'https://unboundedpress.org/api/' + route.params.files + '.files/' + file_metadata._id.$oid + '/binary'" class="w-[85%] h-[88vh]"/>
|
|
||||||
<nuxt-img v-else-if="route.params.filename.split('.').pop()==='jpg'" :src="'https://unboundedpress.org/api/' + route.params.files + '.files/' + file_metadata._id.$oid + '/binary'" class="w-[85%]"/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
|
|
||||||
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
|
||||||
|
|
||||||
const audioPlayerStore = useAudioPlayerStore()
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const { data: file_metadata } = await useFetch('https://unboundedpress.org/api/' + route.params.files + '.files?filter={"filename":"' + route.params.filename + '"}', {
|
|
||||||
//lazy: true,
|
|
||||||
//server: false,
|
|
||||||
transform: (file_metadata) => {
|
|
||||||
return file_metadata[0]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
titleTemplate: 'Michael Winter - Files - ' + route.params.filename
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# Ignore everything
|
|
||||||
*
|
|
||||||
|
|
||||||
# Allow files and directories
|
|
||||||
!/src
|
|
||||||
|
|
||||||
# Ignore unnecessary files inside allowed directories
|
|
||||||
# This should go after the allowed directories
|
|
||||||
**/*~
|
|
||||||
**/*.log
|
|
||||||
**/.DS_Store
|
|
||||||
**/Thumbs.db
|
|
||||||
|
|
@ -9,3 +9,4 @@ dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.fleet
|
.fleet
|
||||||
.idea
|
.idea
|
||||||
|
session-ses_*.md
|
||||||
131
portfolio/AGENTS.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# AGENTS.md - Agent Coding Guidelines
|
||||||
|
|
||||||
|
This document provides guidelines for agents working on this codebase.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- **Framework**: Nuxt 3 (Vue 3)
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **State Management**: Pinia
|
||||||
|
- **UI Components**: Headless UI + Nuxt Icon
|
||||||
|
- **Image Handling**: @nuxt/image
|
||||||
|
- **TypeScript**: Enabled (tsconfig extends .nuxt/tsconfig.json)
|
||||||
|
- **Storybook**: Available for component documentation
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Start development server
|
||||||
|
npm run build # Build for production
|
||||||
|
npm run generate # Generate static site (SSG)
|
||||||
|
npm run preview # Preview production build
|
||||||
|
|
||||||
|
# No test framework configured
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### General Conventions
|
||||||
|
|
||||||
|
- Use Vue 3 Composition API with `<script setup lang="ts">`
|
||||||
|
- TypeScript is preferred for new files; stores use JavaScript (.js)
|
||||||
|
- Follow Nuxt 3 auto-import conventions (no explicit imports for composables, components, etc.)
|
||||||
|
|
||||||
|
### File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
/pages/ - Page components (file-based routing)
|
||||||
|
/components/ - Vue components (auto-imported)
|
||||||
|
/layouts/ - Layout components
|
||||||
|
/server/api/ - Server API routes (Nitro)
|
||||||
|
/stores/ - Pinia stores (.js files)
|
||||||
|
/assets/ - Static assets
|
||||||
|
/public/ - Public static files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Components**: PascalCase (e.g., `Modal.vue`, `IconButton.vue`)
|
||||||
|
- **Files**: kebab-case for pages, PascalCase for components
|
||||||
|
- **Stores**: CamelCase (e.g., `ModalStore.js`, `AudioPlayerStore.js`)
|
||||||
|
- **Props/Emits**: camelCase
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Use withDefaults for optional props
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
persistent?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: false,
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use type-only emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Use toRefs for reactive destructuring
|
||||||
|
const { modelValue } = toRefs(props)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Template content -->
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind CSS
|
||||||
|
|
||||||
|
- Use Tailwind utility classes for all styling
|
||||||
|
- Common classes used: `flex`, `grid`, `fixed`, `relative`, `z-*`, `p-*`, `m-*`, `text-*`, etc.
|
||||||
|
|
||||||
|
### API Routes (Server)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/api/example.ts
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
// Handle request and return data
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store Patterns (Pinia)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// stores/ExampleStore.js
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
|
||||||
|
export const useExampleStore = defineStore("ExampleStore", {
|
||||||
|
state: () => ({ count: 0 }),
|
||||||
|
actions: {
|
||||||
|
increment() {
|
||||||
|
this.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Use try/catch in API routes
|
||||||
|
- Return appropriate HTTP status codes
|
||||||
|
- Handle undefined/null values gracefully
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
- Vue/composables: Use Nuxt auto-imports (no import needed)
|
||||||
|
- External modules: Explicit import
|
||||||
|
- Server-only: Place in `/server/` directory
|
||||||
|
- Path aliases: `@/` maps to project root
|
||||||
|
|
||||||
|
### Additional Notes
|
||||||
|
|
||||||
|
- Project uses Storybook (`.stories.ts` files) for component documentation
|
||||||
|
- Environment variables should use `.env` files (not committed)
|
||||||
|
- Image domains configured for `unboundedpress.org` in nuxt.config.ts
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
FROM node:19-bullseye-slim
|
# Build stage
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
ARG PASSWORD
|
||||||
|
ENV PASSWORD=${PASSWORD}
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
COPY package*.json ./
|
||||||
COPY src/package*.json ./
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
COPY --from=build /src/.output ./.output
|
||||||
|
ENV NITRO_HOST=0.0.0.0
|
||||||
|
ENV NITRO_PORT=5000
|
||||||
|
EXPOSE 5000
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|
|
||||||
34
portfolio/Dockerfile_upgrade
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Build Stage 1
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration
|
||||||
|
COPY package.json .npmrc ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm i
|
||||||
|
|
||||||
|
# Copy the entire project
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# Build Stage 2
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Only `.output` folder is needed from the build stage
|
||||||
|
COPY --from=build /src/.output/ ./
|
||||||
|
|
||||||
|
# Change the port and host
|
||||||
|
ENV PORT=5000
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["node", "/app/server/index.mjs"]
|
||||||
674
portfolio/LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
{one line to give the program's name and a brief idea of what it does.}
|
||||||
|
Copyright (C) {year} {name of author}
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
{project} Copyright (C) {year} {fullname}
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
119
portfolio/README.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# Portfolio
|
||||||
|
|
||||||
|
Michael Winter's portfolio website - a Nuxt 3 application.
|
||||||
|
|
||||||
|
## ⚠️ Important
|
||||||
|
|
||||||
|
This portfolio contains the majority of my life's work - compositions, performances, publications, and research.
|
||||||
|
|
||||||
|
**Before making any changes:**
|
||||||
|
- Ensure you have a backup
|
||||||
|
- Test changes in development first
|
||||||
|
- Be careful with data deletions
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **Framework**: Nuxt 4 (Vue 3, TypeScript, Tailwind CSS)
|
||||||
|
- **Data**: JSON files in `server/data/`
|
||||||
|
- **Admin**: Password-protected admin panel at `/admin`
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- npm
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. Create .env file
|
||||||
|
cp .env_template .env
|
||||||
|
# Edit .env with your values
|
||||||
|
|
||||||
|
# 3. Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables (.env)
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| PASSWORD | Admin password | ************ |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start development server
|
||||||
|
npm run build # Build for production
|
||||||
|
npm run generate # Generate static site
|
||||||
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
The portfolio runs in Docker as part of the main unboundedpress stack.
|
||||||
|
|
||||||
|
### Build & Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
docker compose up -d portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating Admin Password
|
||||||
|
|
||||||
|
1. Edit `.env` in main repo:
|
||||||
|
```
|
||||||
|
PASSWORD=your_new_password
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Rebuild and restart:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Management
|
||||||
|
|
||||||
|
Data is stored in JSON files in `server/data/`:
|
||||||
|
|
||||||
|
- `works.json` - Musical works
|
||||||
|
- `events.json` - Events and performances
|
||||||
|
- `publications.json` - Publications
|
||||||
|
- `resume.json` - CV/resume
|
||||||
|
- `talks.json` - Talks and lectures
|
||||||
|
- `releases.json` - Album releases
|
||||||
|
- `album_art/` - Album cover images
|
||||||
|
- `scores/` - PDF scores
|
||||||
|
- `images/` - Gallery images
|
||||||
|
|
||||||
|
### Editing Data
|
||||||
|
|
||||||
|
1. Via Admin Panel: Visit `/admin` and login
|
||||||
|
2. Direct JSON Edit: Edit files in `server/data/` directly
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
portfolio/
|
||||||
|
├── server/
|
||||||
|
│ ├── api/ # Server API routes
|
||||||
|
│ └── data/ # JSON data files
|
||||||
|
├── pages/ # Vue pages (file-based routing)
|
||||||
|
├── components/ # Vue components
|
||||||
|
├── layouts/ # Layout components
|
||||||
|
├── public/ # Static assets (scores, images)
|
||||||
|
├── stores/ # Pinia stores
|
||||||
|
├── .env # Environment variables (not in repo)
|
||||||
|
└── .env_template # Template for .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- [Nuxt](https://nuxt.com/)
|
||||||
|
- [Vue 3](https://vuejs.org/)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
- [Pinia](https://pinia.vuejs.org/)
|
||||||
|
- [Nuxt Image](https://image.nuxt.com/)
|
||||||
|
- [Headless UI](https://headlessui.com/)
|
||||||
49
portfolio/admin/schemas/index.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
export const schemas = {
|
||||||
|
works: {
|
||||||
|
title: { type: 'text', label: 'Title' },
|
||||||
|
date: { type: 'text', label: 'Date' },
|
||||||
|
type: { type: 'text', label: 'Type' },
|
||||||
|
score: { type: 'text', label: 'Score URL' },
|
||||||
|
gallery: { type: 'text', label: 'Gallery' },
|
||||||
|
soundcloud_trackid: { type: 'text', label: 'SoundCloud Track ID' },
|
||||||
|
vimeo_trackid: { type: 'text', label: 'Vimeo Track ID' },
|
||||||
|
instrument_tags: { type: 'text', label: 'Instrument Tags' },
|
||||||
|
priority: { type: 'number', label: 'Priority' }
|
||||||
|
},
|
||||||
|
publications: {
|
||||||
|
citationKey: { type: 'text', label: 'Citation Key' },
|
||||||
|
entryType: { type: 'text', label: 'Entry Type' },
|
||||||
|
entryTags: { type: 'textarea', label: 'Entry Tags (JSON)' }
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
title: { type: 'text', label: 'Title' },
|
||||||
|
start_date: { type: 'text', label: 'Start Date' },
|
||||||
|
end_date: { type: 'text', label: 'End Date' },
|
||||||
|
location: { type: 'text', label: 'Location' },
|
||||||
|
type: { type: 'text', label: 'Type' },
|
||||||
|
program: { type: 'textarea', label: 'Program' },
|
||||||
|
works_list: { type: 'textarea', label: 'Works List' }
|
||||||
|
},
|
||||||
|
releases: {
|
||||||
|
title: { type: 'text', label: 'Title' },
|
||||||
|
year: { type: 'text', label: 'Year' },
|
||||||
|
album_art: { type: 'text', label: 'Album Art' },
|
||||||
|
discogs_id: { type: 'text', label: 'Discogs ID' },
|
||||||
|
buy_link: { type: 'text', label: 'Buy Link' },
|
||||||
|
spotify_id: { type: 'text', label: 'Spotify ID' }
|
||||||
|
},
|
||||||
|
talks: {
|
||||||
|
title: { type: 'text', label: 'Title' },
|
||||||
|
date: { type: 'text', label: 'Date' },
|
||||||
|
location: { type: 'text', label: 'Location' },
|
||||||
|
type: { type: 'text', label: 'Type' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collections = [
|
||||||
|
{ key: 'works', label: 'Works', file: 'works.json' },
|
||||||
|
{ key: 'publications', label: 'Publications', file: 'publications.json' },
|
||||||
|
{ key: 'events', label: 'Events', file: 'events.json' },
|
||||||
|
{ key: 'releases', label: 'Releases', file: 'releases.json' },
|
||||||
|
{ key: 'talks', label: 'Talks', file: 'talks.json' }
|
||||||
|
]
|
||||||
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 4.1 MiB |
|
|
@ -75,7 +75,7 @@ const toggle = () => {
|
||||||
<Icon
|
<Icon
|
||||||
name="heroicons:chevron-down"
|
name="heroicons:chevron-down"
|
||||||
:class="isOpen ? 'transform rotate-180' : ''"
|
:class="isOpen ? 'transform rotate-180' : ''"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5 text-black"
|
||||||
/>
|
/>
|
||||||
<slot name="title"></slot>
|
<slot name="title"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import { ref, toRefs, watch } from 'vue'
|
import { ref, toRefs, watch } from 'vue'
|
||||||
import Icon from '../Icon/index.vue'
|
|
||||||
import Collapsible from './Collapsible.vue'
|
import Collapsible from './Collapsible.vue'
|
||||||
|
|
||||||
interface CollapsibleItem {
|
interface CollapsibleItem {
|
||||||
|
|
@ -58,4 +58,4 @@
|
||||||
export default {
|
export default {
|
||||||
props: ['upcoming_events']
|
props: ['upcoming_events']
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
66
portfolio/components/IconButton.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<template>
|
||||||
|
<div class="inline-flex p-1 min-w-[25px]">
|
||||||
|
<div v-show="visible" class="bg-black rounded-full text-xs inline-flex" >
|
||||||
|
|
||||||
|
<button v-if="type === 'score'" @click="modalStore.setModalProps('pdf', 'aspect-[1/1.414]', true, '', '', '', link, work.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + work.soundcloud_trackid + '&auto_play=true&show_user=false' : '')" class="inline-flex p-1">
|
||||||
|
<Icon name="ion:book-sharp" style="color: white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a v-else-if="type === 'document'" :href="isExternalLink ? link : undefined" :target="isExternalLink ? '_blank' : undefined" :rel="isExternalLink ? 'noopener noreferrer' : undefined" @click="openDocument()" class="inline-flex p-1 cursor-pointer">
|
||||||
|
<Icon name="ion:book-sharp" style="color: white" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a v-else-if="type === 'buy'" :href="link" :target="newTab ? '_blank' : undefined" :rel="newTab ? 'noopener noreferrer' : undefined" class="inline-flex p-1 cursor-pointer">
|
||||||
|
<Icon name="bxs:purchase-tag" style="color: white" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<NuxtLink v-else-if="type === 'email'" class="inline-flex p-1" :to="link">
|
||||||
|
<Icon name="ic:baseline-email" style="color: white" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<a v-else-if="type === 'discogs'" :href="link" :target="newTab ? '_blank' : undefined" :rel="newTab ? 'noopener noreferrer' : undefined" class="inline-flex p-1 cursor-pointer">
|
||||||
|
<Icon name="simple-icons:discogs" style="color: white" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button @click="audioPlayerStore.setSoundCloudTrackID(work.soundcloud_trackid)" v-else-if="type === 'audio'" class="inline-flex p-1">
|
||||||
|
<Icon name="wpf:speaker" style="color: white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="modalStore.setModalProps('video', 'aspect-video', true, '', '', work.vimeo_trackid)" v-else-if="type === 'video'" class="inline-flex p-1">
|
||||||
|
<Icon name="fluent:video-48-filled" style="color: white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'images', work.gallery, '', '', work.soundcloud_trackid ? 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + work.soundcloud_trackid + '&auto_play=true&show_user=false' : '')" v-else="type === 'image'" class="inline-flex p-1">
|
||||||
|
<Icon name="mdi:camera" style="color: white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps(['type', 'work', 'visible', 'link', 'newTab'])
|
||||||
|
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
const isExternalLink = computed(() => {
|
||||||
|
return props.link && !props.link.endsWith('.pdf') && !props.link.startsWith('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
const isInternalPage = computed(() => {
|
||||||
|
return props.link && props.link.startsWith('/') && !props.link.endsWith('.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDocument = () => {
|
||||||
|
if (props.link?.endsWith('.pdf')) {
|
||||||
|
modalStore.setModalProps('pdf', 'aspect-[1/1.414]', true, '', '', '', props.link)
|
||||||
|
} else if (props.link?.startsWith('/')) {
|
||||||
|
modalStore.setModalProps('document', 'aspect-[1/1.414]', true, '', '', '', '', '', props.link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -21,11 +21,12 @@
|
||||||
'--swiper-navigation-top-offset': '5rem'
|
'--swiper-navigation-top-offset': '5rem'
|
||||||
}"
|
}"
|
||||||
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation]"
|
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation]"
|
||||||
|
class="h-full flex items-center justify-center"
|
||||||
>
|
>
|
||||||
|
|
||||||
<SwiperSlide v-for="image in gallery" class="p-10 bg-zinc-100">
|
<SwiperSlide v-for="image in gallery" class="!flex !items-center !justify-center !h-auto !py-10 !bg-zinc-100">
|
||||||
<nuxt-img :src="'https://unboundedpress.org/api/' + bucket + '.files/' + image.image_id + '/binary'"
|
<NuxtImg :src="'/' + bucket + '/' + image.image"
|
||||||
quality="50"/>
|
style="max-width: calc(100% - 80px); max-height: 70vh; object-fit: contain;"/>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -34,4 +35,4 @@
|
||||||
export default {
|
export default {
|
||||||
props: ['gallery', 'bucket']
|
props: ['gallery', 'bucket']
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -12,11 +12,13 @@ const props = withDefaults(
|
||||||
modelValue?: boolean
|
modelValue?: boolean
|
||||||
persistent?: boolean
|
persistent?: boolean
|
||||||
fullscreen?: boolean
|
fullscreen?: boolean
|
||||||
|
maxHeight?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: false,
|
modelValue: false,
|
||||||
persistent: false,
|
persistent: false,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
|
maxHeight: '85vh',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -96,8 +98,9 @@ provide('modal', api)
|
||||||
class="w-full transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all"
|
class="w-full transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all"
|
||||||
:class="{
|
:class="{
|
||||||
'h-screen': fullscreen,
|
'h-screen': fullscreen,
|
||||||
'max-w-[85vw] rounded-lg': !fullscreen,
|
'max-w-[min(85vw,1200px)] rounded-lg': !fullscreen,
|
||||||
}"
|
}"
|
||||||
|
:style="!fullscreen ? { maxHeight } : {}"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
97
portfolio/layouts/default.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-[63%,35%] w-full font-thin sticky top-0 bg-white p-2 z-20">
|
||||||
|
<div>
|
||||||
|
<div class="text-5xl p-2"> <NuxtLink to='/'>michael winter</NuxtLink></div>
|
||||||
|
<div class="inline-flex text-2xl ml-4">
|
||||||
|
<NuxtLink class="px-3" to='/'>works</NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='/events'>events</NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='/about'>about</NuxtLink>
|
||||||
|
<NuxtLink class="px-3" to='https://unboundedpress.org/code'>code</NuxtLink>
|
||||||
|
<NuxtLink class="px-3 block" to='https://unboundedpress.org/legacy'>legacy</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- hdp link while active -->
|
||||||
|
<!------
|
||||||
|
<div class="inline-flex text-2xl ml-4 font-bold">
|
||||||
|
<NuxtLink class="px-3" to='/a_history_of_the_domino_problem'>A HISTORY OF THE DOMINO PROBLEM | 17.11 - 01.12.2023 </NuxtLink>
|
||||||
|
</div>
|
||||||
|
--->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: this needs to be automatically flipped off when there are no upcoming events-->
|
||||||
|
<!------
|
||||||
|
<div class="px-1 bg-zinc-100 rounded-lg text-center">
|
||||||
|
<div class="text-sm">upcoming events</div>
|
||||||
|
<EventSlider :upcoming_events="upcoming_events" class="max-w-[95%] min-h-[80%]"></EventSlider>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<slot /> <!-- required here only -->
|
||||||
|
<div class="fixed bottom-0 bg-white p-2 w-full flex justify-center z-20">
|
||||||
|
<iframe width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay" v-if="audioPlayerStore.soundcloud_trackid !== 'undefined'"
|
||||||
|
:src="'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/' + audioPlayerStore.soundcloud_trackid + '&inverse=false&auto_play=true&show_user=false'"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-model="modalStore.isOpen" :maxHeight="modalStore.type === 'image' && modalStore.soundcloudUrl ? 'calc(85vh + 60px)' : '85vh'">
|
||||||
|
<ModalBody :class="modalStore.aspect">
|
||||||
|
<ImageSlider v-if="modalStore.type === 'image'" :bucket="modalStore.bucket" :gallery="modalStore.gallery"></ImageSlider>
|
||||||
|
<div v-if="modalStore.type === 'image' && modalStore.soundcloudUrl" class="flex justify-center mt-2">
|
||||||
|
<iframe :src="modalStore.soundcloudUrl" width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay"></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-if="modalStore.type === 'video'" :class="modalStore.aspect" class="w-full h-full flex items-center justify-center p-4">
|
||||||
|
<iframe :src="'https://player.vimeo.com/video/' + modalStore.vimeo_trackid" width="100%" height="100%" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen class="max-w-full max-h-full"></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-if="modalStore.type === 'document'" class="w-full h-full">
|
||||||
|
<iframe :src="modalStore.iframeUrl" width="100%" height="100%" frameborder="0"></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-if="modalStore.type === 'pdf'" class="flex flex-col h-full">
|
||||||
|
<iframe :src="modalStore.pdfUrl + '#toolbar=1&navpanes=0&sidebar=0'" width="100%" height="100%" frameborder="0" :class="[modalStore.soundcloudUrl ? 'max-h-[calc(85vh-60px)]' : 'max-h-[calc(85vh-2rem)]', 'flex-grow']"></iframe>
|
||||||
|
<div v-if="modalStore.soundcloudUrl" class="flex justify-center mt-2">
|
||||||
|
<iframe :src="modalStore.soundcloudUrl" width="400rem" height="20px" scrolling="no" frameborder="no" allow="autoplay"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modalStore.type === 'pdf' || modalStore.type === 'image' || modalStore.type === 'document'" class="absolute bottom-2 right-2 z-10">
|
||||||
|
<a :href="modalStore.type === 'pdf' ? modalStore.pdfUrl : modalStore.type === 'image' ? '/' + modalStore.bucket + '/' + modalStore.gallery[0]?.image : modalStore.type === 'document' ? modalStore.iframeUrl : undefined" target="_blank" rel="noopener noreferrer" class="p-2 bg-gray-600 rounded-lg inline-flex items-center justify-center pointer-events-auto">
|
||||||
|
<Icon name="mdi:open-in-new" class="w-5 h-5 text-white" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
import { useModalStore } from "@/stores/ModalStore"
|
||||||
|
|
||||||
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
|
const modalStore = useModalStore()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
if(process.client && route.params.files == 'scores') {
|
||||||
|
const { data: works } = await useFetch('/api/works', {
|
||||||
|
transform: (works) => {
|
||||||
|
return works.find(w => w.score === route.params.filename)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if(works.value?.soundcloud_trackid){
|
||||||
|
audioPlayerStore.setSoundCloudTrackID(works.value.soundcloud_trackid)
|
||||||
|
} else {
|
||||||
|
audioPlayerStore.clearSoundCloudTrackID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: upcoming_events } = await useFetch('/api/events', {
|
||||||
|
transform: (events) => {
|
||||||
|
const now = new Date().getTime()
|
||||||
|
const upcoming = events.filter(e => new Date(e.start_date).getTime() >= now)
|
||||||
|
for (const event of upcoming) {
|
||||||
|
let date = new Date(event.start_date)
|
||||||
|
event.formatted_date = ("0" + (date.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
||||||
|
}
|
||||||
|
return upcoming.sort((a,b) => new Date(a.start_date) - new Date(b.start_date))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
5
portfolio/layouts/plain.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
echo "**********************************************"
|
|
||||||
echo "Waiting for startup.."
|
|
||||||
sleep 15
|
|
||||||
echo "done"
|
|
||||||
|
|
||||||
echo SETUP.sh time now: `date +"%T" `
|
|
||||||
|
|
||||||
mongo --host mongo:27017 -u ${MONGO_INITDB_ROOT_USERNAME} -p ${MONGO_INITDB_ROOT_PASSWORD} <<EOF
|
|
||||||
|
|
||||||
var cfg = {
|
|
||||||
"_id": "rs0",
|
|
||||||
"protocolVersion": 1,
|
|
||||||
"version": 1,
|
|
||||||
"members": [
|
|
||||||
{
|
|
||||||
"_id": 0,
|
|
||||||
"host": "mongo:27017",
|
|
||||||
"priority": 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
rs.initiate(cfg, { force: true });
|
|
||||||
rs.secondaryOk();
|
|
||||||
db.getMongo().setReadPref('primary');
|
|
||||||
rs.status();
|
|
||||||
EOF
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
echo "**********************************************"
|
|
||||||
echo "Waiting for startup.."
|
|
||||||
sleep 15
|
|
||||||
echo "done"
|
|
||||||
|
|
||||||
echo SETUP.sh time now: `date +"%T" `
|
|
||||||
|
|
||||||
mongosh --host mongo:27017 -u ${MONGO_INITDB_ROOT_USERNAME} -p ${MONGO_INITDB_ROOT_PASSWORD} <<EOF
|
|
||||||
|
|
||||||
var cfg = {
|
|
||||||
"_id": "rs0",
|
|
||||||
"protocolVersion": 1,
|
|
||||||
"version": 1,
|
|
||||||
"members": [
|
|
||||||
{
|
|
||||||
"_id": 0,
|
|
||||||
"host": "mongo:27017",
|
|
||||||
"priority": 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
rs.initiate(cfg, { force: true });
|
|
||||||
rs.secondaryOk();
|
|
||||||
db.getMongo().setReadPref('primary');
|
|
||||||
rs.status();
|
|
||||||
EOF
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//import { defineNuxtConfig } from 'nuxt3'
|
|
||||||
|
|
||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxtjs/tailwindcss', '@nuxt/image', 'nuxt-icon', '@pinia/nuxt', 'nuxt-headlessui', 'nuxt-swiper'],
|
runtimeConfig: {
|
||||||
extends: ['nuxt-umami'],
|
adminPassword: process.env.PASSWORD
|
||||||
|
},
|
||||||
|
modules: ['@nuxtjs/tailwindcss', '@nuxt/image', '@nuxt/icon', '@pinia/nuxt', 'nuxt-headlessui', 'nuxt-swiper', 'nuxt-umami'],
|
||||||
image: {
|
image: {
|
||||||
domains: ['unboundedpress.org']
|
domains: ['unboundedpress.org']
|
||||||
},
|
},
|
||||||
|
|
@ -22,8 +22,6 @@ export default defineNuxtConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
routeRules: {
|
routeRules: {
|
||||||
'/cv': { redirect: '/legacy/cv' },
|
|
||||||
'/works_list': { redirect: '/legacy/works_list' },
|
|
||||||
'/hdp': { redirect: '/a_history_of_the_domino_problem' },
|
'/hdp': { redirect: '/a_history_of_the_domino_problem' },
|
||||||
},
|
},
|
||||||
nitro: {
|
nitro: {
|
||||||
13166
portfolio/package-lock.json
generated
Normal file
36
portfolio/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "nuxt-app",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/bxs": "^1.2.2",
|
||||||
|
"@iconify-json/fluent": "^1.2.39",
|
||||||
|
"@iconify-json/heroicons": "^1.2.3",
|
||||||
|
"@iconify-json/ion": "^1.2.6",
|
||||||
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
|
"@iconify-json/simple-icons": "^1.2.71",
|
||||||
|
"@iconify-json/wpf": "^1.2.0",
|
||||||
|
"@nuxt/icon": "^2.2.1",
|
||||||
|
"@nuxt/image": "^2.0.0",
|
||||||
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"nuxt-headlessui": "^1.2.2",
|
||||||
|
"nuxt-icon": "^1.0.0-beta.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@formkit/themes": "^1.7.2",
|
||||||
|
"@formkit/vue": "^1.7.2",
|
||||||
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"nuxt": "^4.3.1",
|
||||||
|
"nuxt-swiper": "^1.2.2",
|
||||||
|
"nuxt-umami": "^3.2.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,34 +23,6 @@
|
||||||
For the Lecture-Concert on 22 Nov 2023, Registration recommended. Sign up <NuxtLink class="text-2xl font-bold" to='https://www.eventbrite.de/e/a-history-of-the-domino-problem-lecture-concert-tickets-707700981687'>HERE</NuxtLink>.
|
For the Lecture-Concert on 22 Nov 2023, Registration recommended. Sign up <NuxtLink class="text-2xl font-bold" to='https://www.eventbrite.de/e/a-history-of-the-domino-problem-lecture-concert-tickets-707700981687'>HERE</NuxtLink>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!---
|
|
||||||
<Swiper
|
|
||||||
:loop="true"
|
|
||||||
:spaceBetween="30"
|
|
||||||
:centeredSlides="true"
|
|
||||||
:pagination='{
|
|
||||||
clickable: true,
|
|
||||||
renderBullet: function (index, className) {
|
|
||||||
return "<span class=" + className + ">" + ["about", "exhibition", "events", "participants"][index] + "</span>";
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
:navigation="false"
|
|
||||||
:style="{
|
|
||||||
'--swiper-pagination-color': 'rgb(255 255 255)',
|
|
||||||
'--swiper-pagination-bullet-horizontal-gap': '80px',
|
|
||||||
'--swiper-pagination-bullet-inactive-opacity': '0.8',
|
|
||||||
'--swiper-pagination-bullet-size': '0px',
|
|
||||||
'--swiper-pagination-bottom': 'auto',
|
|
||||||
'--swiper-pagination-top': '1rem',
|
|
||||||
'--swiper-pagination-margin-left': '0px',
|
|
||||||
}"
|
|
||||||
:hashNavigation="{
|
|
||||||
watchState: true,
|
|
||||||
}"
|
|
||||||
:modules="[SwiperAutoplay, SwiperPagination, SwiperNavigation, SwiperHashNavigation]"
|
|
||||||
class="max-w-[95vw]"
|
|
||||||
>
|
|
||||||
--->
|
|
||||||
<Swiper
|
<Swiper
|
||||||
:loop="true"
|
:loop="true"
|
||||||
:spaceBetween="30"
|
:spaceBetween="30"
|
||||||
|
|
@ -103,10 +75,10 @@
|
||||||
Lichthof Ost, HU Berlin Hauptgebäude, Campus Mitte, Unter den Linden 6 (U-Bahn Unter den Linden oder Museuminsel)
|
Lichthof Ost, HU Berlin Hauptgebäude, Campus Mitte, Unter den Linden 6 (U-Bahn Unter den Linden oder Museuminsel)
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
<NuxtLink class="px-3" to='https://unboundedpress.org/pubs/a_few_thoughts_exhibition_poster.pdf'><nuxt-img class="w-[500px]" src="/hdp_images/hdp_exhibition_poster_digital.jpeg"/></NuxtLink>
|
<NuxtLink class="px-3" to='/pubs/a_few_thoughts_exhibition_poster.pdf'><nuxt-img class="w-[500px]" src="/hdp_images/hdp_exhibition_poster_digital.jpeg"/></NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
<NuxtLink class="px-3" to='https://unboundedpress.org/hdp_images/lichthof_ost_map.jpeg'><nuxt-img class="w-[500px]" src="/hdp_images/lichthof_ost_map.jpeg"/></NuxtLink>
|
<NuxtLink class="px-3" to='/hdp_images/lichthof_ost_map.jpeg'><nuxt-img class="w-[500px]" src="/hdp_images/lichthof_ost_map.jpeg"/></NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
<span class="font-bold">About the Exhibition</span>
|
<span class="font-bold">About the Exhibition</span>
|
||||||
|
|
@ -178,7 +150,7 @@
|
||||||
<span class="swiper-no-swiping">
|
<span class="swiper-no-swiping">
|
||||||
<div class="max-h-[800px] overflow-auto">
|
<div class="max-h-[800px] overflow-auto">
|
||||||
<div class="mb-5 py-10">
|
<div class="mb-5 py-10">
|
||||||
<NuxtLink class="text-3xl font-bold" to='https://unboundedpress.org/'>Michael Winter - composer | sound artist</NuxtLink>
|
<NuxtLink class="text-3xl font-bold" to='/'>Michael Winter - composer | sound artist</NuxtLink>
|
||||||
<div class="grid grid-cols-[20%,70%] p-5">
|
<div class="grid grid-cols-[20%,70%] p-5">
|
||||||
<nuxt-img src="/hdp_images/michael.jpg"/>
|
<nuxt-img src="/hdp_images/michael.jpg"/>
|
||||||
<div class="px-5">
|
<div class="px-5">
|
||||||
|
|
@ -309,13 +281,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-5 text-2xl">
|
<div class="mb-5 text-2xl">
|
||||||
To download the poster for the project, click
|
To download the poster for the project, click
|
||||||
<NuxtLink class="inline-flex p-1" to='https://unboundedpress.org/pubs/hdp_poster.pdf'>
|
<NuxtLink class="inline-flex p-1" to='/pubs/hdp_poster.pdf'>
|
||||||
<span class="font-bold">HERE</span>
|
<span class="font-bold">HERE</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-5 text-2xl">
|
<div class="mb-5 text-2xl">
|
||||||
To download the poster specifically for the exhibition, click
|
To download the poster specifically for the exhibition, click
|
||||||
<NuxtLink class="inline-flex p-1" to='https://unboundedpress.org/pubs/a_few_thoughts_exhibition_poster.pdf'>
|
<NuxtLink class="inline-flex p-1" to='/pubs/a_few_thoughts_exhibition_poster.pdf'>
|
||||||
<span class="font-bold">HERE</span>
|
<span class="font-bold">HERE</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -327,20 +299,11 @@
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import hdp_background from "assets/hdp_background.png";
|
import hdp_background from "assets/hdp_background.png"
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
image: hdp_background, };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
import { useAudioPlayerStore } from "@/stores/AudioPlayerStore"
|
||||||
|
|
||||||
|
const image = hdp_background
|
||||||
const audioPlayerStore = useAudioPlayerStore()
|
const audioPlayerStore = useAudioPlayerStore()
|
||||||
audioPlayerStore.setSoundCloudTrackID(324252345)
|
audioPlayerStore.setSoundCloudTrackID(324252345)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -33,21 +33,21 @@
|
||||||
<div class="inline-flex place-items-center p-2">
|
<div class="inline-flex place-items-center p-2">
|
||||||
Contact
|
Contact
|
||||||
<div>
|
<div>
|
||||||
<IconButton :visible="true" type="email" work="placeholder" link="javascript:location='mailto:\u006d\u0077\u0069\u006e\u0074\u0065\u0072\u0040\u0075\u006e\u0062\u006f\u0075\u006e\u0064\u0065\u0064\u0070\u0072\u0065\u0073\u0073\u002e\u006f\u0072\u0067';void 0"></IconButton>
|
<IconButton :visible="true" type="email" work="placeholder" link="javascript:location='mailto:\u006d\u0077\u0069\u006e\u0074\u0065\u0072\u0040\u0075\u006e\u0062\u006f\u0075\u006e\u0064\u0065\u0064\u0070\u0072\u0065\u0073\u0073\u002e\u006f\u0072\u0067';void 0" class="mt-[-6px]"></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
<div class="inline-flex place-items-center p-2">
|
<div class="inline-flex place-items-center p-2">
|
||||||
CV
|
CV
|
||||||
<div>
|
<div>
|
||||||
<IconButton :visible="true" type="document" work="placeholder" link="https://unboundedpress.org/legacy/cv"></IconButton>
|
<IconButton :visible="true" type="document" work="placeholder" link="/cv" class="mt-[-6px]"></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
<div class="inline-flex place-items-center p-2">
|
<div class="inline-flex place-items-center p-2">
|
||||||
Works List with Presentation History
|
Works List with Presentation History
|
||||||
<div>
|
<div>
|
||||||
<IconButton :visible="true" type="document" work="placeholder" link="https://unboundedpress.org/legacy/works_list"></IconButton>
|
<IconButton :visible="true" type="document" work="placeholder" link="/works_list" class="mt-[-6px]"></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
|
|
@ -62,16 +62,7 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
const { data: images } = await useFetch('https://unboundedpress.org/api/images.files?pagesize=200')
|
const { data: gallery } = await useFetch('/api/my_image_gallery')
|
||||||
|
|
||||||
const { data: gallery } = await useFetch('https://unboundedpress.org/api/my_image_gallery?pagesize=200', {
|
|
||||||
transform: (gallery) => {
|
|
||||||
for (const item of gallery) {
|
|
||||||
item.image_id = images.value.find(obj => {return obj.filename === item.image})._id.$oid
|
|
||||||
}
|
|
||||||
return gallery //.sort((a,b) => a.priority - b.priority)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
titleTemplate: 'Michael Winter - About - Short Bio, Contact, CV, Works List, and Mailing List'
|
titleTemplate: 'Michael Winter - About - Short Bio, Contact, CV, Works List, and Mailing List'
|
||||||
339
portfolio/pages/admin.vue
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-100">
|
||||||
|
<div v-if="!authenticated" class="flex items-center justify-center min-h-screen">
|
||||||
|
<FormKit type="form" @submit="checkPassword" submit-label="Login">
|
||||||
|
<FormKit
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
validation="required"
|
||||||
|
/>
|
||||||
|
</FormKit>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex min-h-screen">
|
||||||
|
<div class="w-64 bg-white border-r p-4">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Admin</h2>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Collections</h3>
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<button
|
||||||
|
v-for="col in collections"
|
||||||
|
:key="col.key"
|
||||||
|
@click="selectedView = 'collections'; selectedCollection = col.key"
|
||||||
|
class="w-full text-left px-4 py-2 rounded text-sm"
|
||||||
|
:class="selectedView === 'collections' && selectedCollection === col.key ? 'bg-black text-white' : 'hover:bg-gray-100'"
|
||||||
|
>
|
||||||
|
{{ col.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Files</h3>
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<button
|
||||||
|
v-for="folder in fileFolders"
|
||||||
|
:key="folder.key"
|
||||||
|
@click="selectedView = 'files'; selectedFolder = folder.key"
|
||||||
|
class="w-full text-left px-4 py-2 rounded text-sm"
|
||||||
|
:class="selectedView === 'files' && selectedFolder === folder.key ? 'bg-black text-white' : 'hover:bg-gray-100'"
|
||||||
|
>
|
||||||
|
{{ folder.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="logout" class="mt-8 w-full px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 p-8 overflow-auto">
|
||||||
|
<!-- Collections View -->
|
||||||
|
<template v-if="selectedView === 'collections'">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">{{ collections.find(c => c.key === selectedCollection)?.label }}</h1>
|
||||||
|
<button @click="createNew" class="px-4 py-2 bg-black text-white rounded hover:bg-gray-800">
|
||||||
|
Add New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="w-full mb-4 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-black"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden mb-8">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Title</th>
|
||||||
|
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in filteredItems" :key="item.id" class="border-t hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">{{ getTitle(item) }}</td>
|
||||||
|
<td class="px-4 py-3 space-x-2">
|
||||||
|
<button @click="viewRawJson(item)" class="text-green-600 hover:text-green-800">JSON</button>
|
||||||
|
<button @click="deleteItem(item)" class="text-red-600 hover:text-red-800">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Files View -->
|
||||||
|
<template v-else-if="selectedView === 'files'">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">{{ fileFolders.find(f => f.key === selectedFolder)?.label }}</h1>
|
||||||
|
<label class="px-4 py-2 bg-black text-white rounded hover:bg-gray-800 cursor-pointer">
|
||||||
|
{{ isUploading ? 'Uploading...' : 'Upload File' }}
|
||||||
|
<input type="file" class="hidden" @change="handleFileUpload" :disabled="isUploading" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="fileSearchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search files..."
|
||||||
|
class="w-full mb-4 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-black"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">File</th>
|
||||||
|
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Size</th>
|
||||||
|
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="file in filteredFiles" :key="file.name" class="border-t hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">{{ file.name }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500">{{ (file.size / 1024).toFixed(1) }} KB</td>
|
||||||
|
<td class="px-4 py-3 space-x-2">
|
||||||
|
<button @click="copyUrl(file.url)" class="text-blue-600 hover:text-blue-800">Copy URL</button>
|
||||||
|
<button @click="deleteFile(file)" class="text-red-600 hover:text-red-800">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="rawJsonItem" class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl h-[80vh] flex flex-col p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Raw JSON</h2>
|
||||||
|
<textarea
|
||||||
|
v-model="rawJsonContent"
|
||||||
|
class="flex-1 w-full font-mono text-sm border rounded p-4 resize-none"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<button @click="rawJsonItem = null" class="px-4 py-2 border rounded hover:bg-gray-50">Cancel</button>
|
||||||
|
<button @click="saveRawJson" class="px-4 py-2 bg-black text-white rounded hover:bg-gray-800">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import { collections } from '@/admin/schemas'
|
||||||
|
|
||||||
|
const password = ref('')
|
||||||
|
const authenticated = ref(false)
|
||||||
|
const selectedView = ref('collections')
|
||||||
|
const selectedCollection = ref('works')
|
||||||
|
const selectedFolder = ref('scores')
|
||||||
|
const items = ref([])
|
||||||
|
const files = ref([])
|
||||||
|
const rawJsonItem = ref(null)
|
||||||
|
const rawJsonContent = ref('')
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const fileSearchQuery = ref('')
|
||||||
|
|
||||||
|
const searchFields = {
|
||||||
|
works: ['title', 'type', 'instrument_tags'],
|
||||||
|
publications: ['entryTags.title', 'entryTags.year', 'citationKey'],
|
||||||
|
events: ['venue.name', 'venue.city', 'start_date'],
|
||||||
|
releases: ['title', 'year'],
|
||||||
|
talks: ['title', 'location', 'date']
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return items.value
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
const fields = searchFields[selectedCollection.value] || []
|
||||||
|
|
||||||
|
return items.value.filter(item => {
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = field.split('.').reduce((obj, key) => obj?.[key], item)
|
||||||
|
if (value && String(value).toLowerCase().includes(query)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredFiles = computed(() => {
|
||||||
|
if (!fileSearchQuery.value.trim()) return files.value
|
||||||
|
const normalize = (str) => str.toLowerCase().replace(/[\s_-]+/g, '')
|
||||||
|
const query = normalize(fileSearchQuery.value)
|
||||||
|
|
||||||
|
return files.value.filter(file => normalize(file.name).includes(query))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileFolders = [
|
||||||
|
{ key: 'scores', label: 'Scores' },
|
||||||
|
{ key: 'pubs', label: 'Publications' },
|
||||||
|
{ key: 'album_art', label: 'Album Art' },
|
||||||
|
{ key: 'images', label: 'Images' },
|
||||||
|
{ key: 'hdp_images', label: 'HDP Images' }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function checkPassword(data) {
|
||||||
|
try {
|
||||||
|
const result = await $fetch('/api/auth/verify-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { password: data.password }
|
||||||
|
})
|
||||||
|
if (result.valid) {
|
||||||
|
authenticated.value = true
|
||||||
|
loadItems()
|
||||||
|
} else {
|
||||||
|
alert('Incorrect password')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Password check failed:', e)
|
||||||
|
alert('Error: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authenticated.value = false
|
||||||
|
password.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitle(item) {
|
||||||
|
if (selectedCollection.value === 'publications' && item.entryTags?.title) {
|
||||||
|
return item.entryTags.title
|
||||||
|
}
|
||||||
|
if (selectedCollection.value === 'events' && item.start_date) {
|
||||||
|
const v = item.venue
|
||||||
|
return `${item.start_date}: ${v?.name} - ${v?.city}, ${v?.state}`
|
||||||
|
}
|
||||||
|
if (selectedCollection.value === 'talks' && item.date) {
|
||||||
|
return `${item.date}: ${item.location}`
|
||||||
|
}
|
||||||
|
return item.title || item.citationKey || item.name || item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
const { data } = await useFetch(`/api/admin/${selectedCollection.value}`)
|
||||||
|
items.value = data.value || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const { data } = await useFetch(`/api/admin/files?folder=${selectedFolder.value}`)
|
||||||
|
files.value = data.value || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNew() {
|
||||||
|
rawJsonItem.value = { id: null }
|
||||||
|
rawJsonContent.value = '{\n \n}'
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewRawJson(item) {
|
||||||
|
rawJsonItem.value = item
|
||||||
|
rawJsonContent.value = JSON.stringify(item, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRawJson() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawJsonContent.value)
|
||||||
|
const method = parsed.id ? 'PUT' : 'POST'
|
||||||
|
await useFetch(`/api/admin/${selectedCollection.value}`, {
|
||||||
|
method,
|
||||||
|
body: parsed
|
||||||
|
})
|
||||||
|
rawJsonItem.value = null
|
||||||
|
loadItems()
|
||||||
|
} catch (e) {
|
||||||
|
alert('Invalid JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(item) {
|
||||||
|
if (confirm('Are you sure you want to delete this item?')) {
|
||||||
|
await useFetch(`/api/admin/${selectedCollection.value}/${item.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
loadItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
isUploading.value = true
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/files/upload?folder=${selectedFolder.value}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
loadFiles()
|
||||||
|
} catch (e) {
|
||||||
|
alert('Upload failed: ' + e.message)
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(file) {
|
||||||
|
if (confirm(`Delete ${file.name}?`)) {
|
||||||
|
await $fetch(`/api/admin/files?folder=${selectedFolder.value}&file=${file.name}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUrl(url) {
|
||||||
|
navigator.clipboard.writeText(window.location.origin + url)
|
||||||
|
alert('URL copied to clipboard!')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedCollection, () => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedFolder, () => {
|
||||||
|
loadFiles()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedView, (newView) => {
|
||||||
|
if (newView === 'collections') {
|
||||||
|
loadItems()
|
||||||
|
} else if (newView === 'files') {
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
437
portfolio/pages/cv.vue
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'plain'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: resumeData } = await useFetch('/api/resume')
|
||||||
|
const { data: talksData } = await useFetch('/api/talks')
|
||||||
|
const resume = computed(() => resumeData.value)
|
||||||
|
|
||||||
|
const talksByYear = computed(() => {
|
||||||
|
if (!talksData.value) return []
|
||||||
|
|
||||||
|
const byYear = {}
|
||||||
|
for (const talk of talksData.value) {
|
||||||
|
const year = talk.date ? new Date(talk.date).getFullYear() : 'Unknown'
|
||||||
|
if (!byYear[year]) byYear[year] = []
|
||||||
|
byYear[year].push(talk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(byYear)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.map(year => {
|
||||||
|
const talks = byYear[year]
|
||||||
|
|
||||||
|
const byLocation = {}
|
||||||
|
for (const talk of talks) {
|
||||||
|
const key = `${talk.location}|||${talk.date}`
|
||||||
|
if (!byLocation[key]) byLocation[key] = []
|
||||||
|
byLocation[key].push(talk)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = Object.values(byLocation).map(group => ({
|
||||||
|
location: group[0].location,
|
||||||
|
date: group[0].date,
|
||||||
|
titles: group.map(t => t.title)
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { year, groups }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatMonth(dateStr) {
|
||||||
|
if (!dateStr) return 'Present'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date)) return dateStr
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYear(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date)) return dateStr
|
||||||
|
return date.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cv-container">
|
||||||
|
<header class="cv-header">
|
||||||
|
<h1>Michael Winter</h1>
|
||||||
|
<h3>Curriculum Vitae</h3>
|
||||||
|
<p class="contact">
|
||||||
|
{{ resume?.basics?.email }} · {{ resume?.basics?.phone }} · {{ resume?.basics?.website }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<!-- Education -->
|
||||||
|
<section v-if="resume?.education?.length" class="cv-section">
|
||||||
|
<h4>Education</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="(edu, idx) in resume.education" :key="idx" class="item">
|
||||||
|
<span class="item-title">{{ edu.studyType }} in {{ edu.area }}</span>
|
||||||
|
<span class="item-meta">{{ edu.institution }}, {{ formatYear(edu.endDate) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Teaching -->
|
||||||
|
<section v-if="resume?.teaching?.length" class="cv-section">
|
||||||
|
<h4>Teaching</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="(teach, idx) in resume.teaching" :key="idx" class="item">
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-title">{{ teach.company }}</span>
|
||||||
|
<span class="item-subtitle">{{ teach.position }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">{{ formatMonth(teach.startDate) }} – {{ formatMonth(teach.endDate) }}</div>
|
||||||
|
<ul v-if="teach.highlights" class="item-list">
|
||||||
|
<li v-for="h in teach.highlights">{{ h }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Lectures -->
|
||||||
|
<section v-if="talksByYear.length" class="cv-section">
|
||||||
|
<h4>Lectures</h4>
|
||||||
|
<div v-for="yearGroup in talksByYear" :key="yearGroup.year" class="year-group">
|
||||||
|
<div class="year-header">{{ yearGroup.year }}</div>
|
||||||
|
<div v-for="(group, idx) in yearGroup.groups" :key="idx" class="item">
|
||||||
|
<span class="item-title">{{ group.location }}</span>
|
||||||
|
<template v-for="(title, tidx) in group.titles" :key="tidx">
|
||||||
|
<div class="item-detail talk-title" v-if="Array.isArray(title)">
|
||||||
|
<em v-for="(t, i) in title" :key="i" style="display: block;">{{ t }}</em>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail talk-title" v-else>
|
||||||
|
<em style="display: block;">{{ title }}</em>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Relevant Work -->
|
||||||
|
<section v-if="resume?.work?.length" class="cv-section">
|
||||||
|
<h4>Relevant Work</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="(w, idx) in resume.work" :key="idx" class="item">
|
||||||
|
<div class="item-header">
|
||||||
|
<span class="item-title">{{ w.company }}</span>
|
||||||
|
<span class="item-subtitle">{{ w.position }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-detail">{{ formatMonth(w.startDate) }} – {{ formatMonth(w.endDate) }}</div>
|
||||||
|
<ul v-if="w.highlights" class="item-list">
|
||||||
|
<li v-for="h in w.highlights">{{ h }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Skills -->
|
||||||
|
<section v-if="resume?.skills?.length" class="cv-section">
|
||||||
|
<h4>Coding Skills</h4>
|
||||||
|
<p class="item-detail">
|
||||||
|
<span v-for="(skill, idx) in resume.skills" :key="idx">
|
||||||
|
{{ skill.keywords?.join(', ') }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Languages -->
|
||||||
|
<section v-if="resume?.languages?.length" class="cv-section">
|
||||||
|
<h4>Language Skills</h4>
|
||||||
|
<p class="item-detail">
|
||||||
|
<span v-for="(lang, idx) in resume.languages" :key="idx">
|
||||||
|
{{ lang.language }} — {{ lang.fluency }}{{ idx < resume.languages.length - 1 ? '; ' : '' }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Publications -->
|
||||||
|
<section v-if="resume?.publications?.length" class="cv-section">
|
||||||
|
<h4>Publications</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="pub in resume.publications" :key="pub.id" class="item">
|
||||||
|
<div class="item-title" v-html="pub.entryTags?.title"></div>
|
||||||
|
<div class="bib">
|
||||||
|
{{ pub.entryTags?.author }}
|
||||||
|
<span v-if="pub.entryTags?.editor">, editors {{ pub.entryTags.editor }}.</span>
|
||||||
|
<span v-if="pub.entryTags?.booktitle"><em>{{ pub.entryTags.booktitle }}.</em></span>
|
||||||
|
<span v-if="pub.entryTags?.journal"><em>{{ pub.entryTags.journal }}</em>,</span>
|
||||||
|
<span v-if="pub.entryTags?.volume">vol. {{ pub.entryTags.volume }}</span>
|
||||||
|
<span v-if="pub.entryTags?.publisher">{{ pub.entryTags.publisher }},</span> {{ pub.entryTags?.year }}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recordings -->
|
||||||
|
<section v-if="resume?.solo_releases?.length || resume?.compilation_releases?.length" class="cv-section">
|
||||||
|
<h4>Recordings</h4>
|
||||||
|
|
||||||
|
<div v-if="resume?.solo_releases?.length" class="subsection">
|
||||||
|
<div class="subsection-title"><strong>Solo Albums</strong></div>
|
||||||
|
<div v-for="(rel, idx) in resume.solo_releases" :key="idx" class="item recording-item">
|
||||||
|
<span class="item-title">{{ rel.title }}</span>
|
||||||
|
<span class="item-detail">{{ rel.publisher }}. {{ rel.media_type }}. {{ rel.date }}.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resume?.compilation_releases?.length" class="subsection">
|
||||||
|
<div class="subsection-title"><strong>Compilation Albums</strong></div>
|
||||||
|
<div v-for="(rel, idx) in resume.compilation_releases" :key="idx" class="item recording-item">
|
||||||
|
<span class="item-title">{{ rel.title }}</span>
|
||||||
|
<span class="item-detail">{{ rel.publisher }}. {{ rel.media_type }}. {{ rel.date }}.</span>
|
||||||
|
<div class="item-detail">featuring <span class="italic">{{ rel.work }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Residencies -->
|
||||||
|
<section v-if="resume?.residencies?.length" class="cv-section">
|
||||||
|
<h4>Residencies and Awards</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="(res, idx) in resume.residencies" :key="idx" class="item">
|
||||||
|
<span class="item-title">{{ res.org }}</span>
|
||||||
|
<span class="item-meta">{{ res.date }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- References -->
|
||||||
|
<section v-if="resume?.references?.length" class="cv-section">
|
||||||
|
<h4>References</h4>
|
||||||
|
<div class="cv-entry">
|
||||||
|
<div v-for="ref in resume.references" :key="ref.id" class="item">
|
||||||
|
<span class="item-title">{{ ref.name }}</span>
|
||||||
|
<span class="item-detail">{{ ref.position }}</span>
|
||||||
|
<span class="item-detail">{{ ref.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cv-container {
|
||||||
|
font-size: 12px;
|
||||||
|
width: 175mm;
|
||||||
|
margin: 40px auto;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 30px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header .contact {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-entry {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-subtitle {
|
||||||
|
font-style: italic;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talk-title {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-header {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bib {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 16px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #222;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-container {
|
||||||
|
margin: 0;
|
||||||
|
padding: 15mm;
|
||||||
|
width: auto;
|
||||||
|
font-size: 10pt;
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h1 {
|
||||||
|
font-size: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h3 {
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section h4 {
|
||||||
|
font-size: 10pt;
|
||||||
|
border-bottom: 1pt solid #999;
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta,
|
||||||
|
.item-detail,
|
||||||
|
.item-list {
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-header {
|
||||||
|
font-size: 10pt;
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-top: 1pt solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-entry,
|
||||||
|
.year-group,
|
||||||
|
.subsection {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -56,17 +56,17 @@
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { data: events } = await useFetch('https://unboundedpress.org/api/events?pagesize=200', {
|
const { data: events } = await useFetch('/api/events', {
|
||||||
transform: (events) => {
|
transform: (events) => {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
let date = new Date(event.start_date.$date)
|
let date = new Date(event.start_date)
|
||||||
event.formatted_date = ("0" + (date.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
event.formatted_date = ("0" + (date.getMonth() + 1)).slice(-2) + "." + ("0" + date.getDate()).slice(-2) + "." + date.getFullYear()
|
||||||
}
|
}
|
||||||
return events.sort((a,b) => b.start_date.$date - a.start_date.$date)
|
return events.sort((a,b) => new Date(b.start_date) - new Date(a.start_date))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: lectures } = await useFetch('https://unboundedpress.org/api/talks?pagesize=200', {
|
const { data: lectures } = await useFetch('/api/talks', {
|
||||||
transform: (events) => {
|
transform: (events) => {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
let date = new Date(event.date)
|
let date = new Date(event.date)
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
event.talks = talks
|
event.talks = talks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return events.sort((a,b) => b.date - a.date)
|
return events.sort((a,b) => new Date(b.date) - new Date(a.date))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
<div class="py-2 ml-3" v-for="item in works">
|
<div class="py-2 ml-3" v-for="item in works">
|
||||||
<p class="font-thin">{{ item.year }}</p>
|
<p class="font-thin">{{ item.year }}</p>
|
||||||
<div class="leading-tight py-1 ml-3" v-for="work in item.works">
|
<div class="leading-tight py-1 ml-3" v-for="work in item.works">
|
||||||
<div class="grid grid-cols-[65%,30%] gap-1 font-thin">
|
<div class="grid grid-cols-[65%,30%] gap-1 font-thin items-start">
|
||||||
<div class="italic text-sm">{{ work.title }}</div>
|
<div class="italic text-sm">{{ work.title }}</div>
|
||||||
<div class="inline-flex">
|
<div class="inline-flex mt-[-4px]">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<IconButton :visible="work.score" type="score" :work="work" :link="work.score" class="inline-flex p-1"></IconButton>
|
<IconButton :visible="work.score" type="score" :work="work" :link="work.score" class="inline-flex p-1"></IconButton>
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
<p class="text-lg">writings</p>
|
<p class="text-lg">writings</p>
|
||||||
|
|
||||||
<div class="leading-tight py-2 ml-3 text-sm" v-for="item in pubs">
|
<div class="leading-tight py-2 ml-3 text-sm" v-for="item in pubs">
|
||||||
<div class="grid grid-cols-[95%,5%] gap-1">
|
<div class="grid grid-cols-[95%,5%] gap-1 items-start">
|
||||||
<div>
|
<div>
|
||||||
<span v-html="item.entryTags.title"></span>
|
<span v-html="item.entryTags.title"></span>
|
||||||
<div class="ml-4 text-[#7F7F7F]">
|
<div class="ml-4 text-[#7F7F7F]">
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<IconButton :visible=item.entryTags.howpublished type="document" :link="item.entryTags.howpublished" class="inline-flex p-1"></IconButton>
|
<IconButton :visible=item.entryTags.howpublished type="document" :link="item.entryTags.howpublished" class="inline-flex p-1 mt-[-6px]"></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,15 +59,15 @@
|
||||||
|
|
||||||
<div class="px-5">
|
<div class="px-5">
|
||||||
<p class="text-lg">albums</p>
|
<p class="text-lg">albums</p>
|
||||||
<div class="leading-tight py-4 ml-3 text-sm" v-for="item in releases">
|
<div class="flex flex-col items-center leading-tight py-4 text-sm" v-for="item in releases">
|
||||||
<p class="text-center leading-tight py-2">{{ item.title }}</p>
|
<p class="leading-tight py-2">{{ item.title }}</p>
|
||||||
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image_id: item.album_art_id}], '')">
|
<button @click="modalStore.setModalProps('image', 'aspect-auto', true, 'album_art', [{image: item.album_art}], '')">
|
||||||
<nuxt-img :src="'https://unboundedpress.org/api/album_art.files/' + item.album_art_id + '/binary'"
|
<nuxt-img :src="'/album_art/' + item.album_art"
|
||||||
quality="50"/>
|
quality="50"/>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex place-content-center place-items-center">
|
<div class="flex place-content-center place-items-center">
|
||||||
<IconButton :visible="item.discogs_id" type="discogs" :link="'https://www.discogs.com/release/' + item.discogs_id"></IconButton>
|
<IconButton :visible="item.discogs_id" type="discogs" :link="'https://www.discogs.com/release/' + item.discogs_id" :newTab="true"></IconButton>
|
||||||
<IconButton :visible="item.buy_link" type="buy" :link="item.buy_link"></IconButton>
|
<IconButton :visible="item.buy_link" type="buy" :link="item.buy_link" :newTab="true"></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,115 +84,68 @@
|
||||||
const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{});
|
const groupBy = (x,f)=>x.reduce((a,b,i)=>((a[f(b,i,x)]||=[]).push(b),a),{});
|
||||||
|
|
||||||
const isValidUrl = urlString => {
|
const isValidUrl = urlString => {
|
||||||
/*
|
|
||||||
var urlPattern = new RegExp('^(https?:\\/\\/)?'+ // validate protocol
|
|
||||||
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // validate domain name
|
|
||||||
'((\\d{1,3}\\.){3}\\d{1,3}))'+ // validate OR ip (v4) address
|
|
||||||
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // validate port and path
|
|
||||||
'(\\?[;&a-z\\d%_.~+=-]*)?'+ // validate query string
|
|
||||||
'(\\#[-a-z\\d_]*)?$','i'); // validate fragment locator
|
|
||||||
return !!urlPattern.test(urlString);
|
|
||||||
*/
|
|
||||||
|
|
||||||
var pattern = /^((http|https|ftp):\/\/)/;
|
var pattern = /^((http|https|ftp):\/\/)/;
|
||||||
return pattern.test(urlString)
|
return pattern.test(urlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const { data: images } = await useFetch('https://unboundedpress.org/api/images.files?pagesize=200')
|
const { data: images } = await useFetch('/api/images')
|
||||||
|
|
||||||
const { data: works } = await useFetch('https://unboundedpress.org/api/works?pagesize=200', {
|
const { data: works } = await useFetch('/api/works', {
|
||||||
transform: (works) => {
|
transform: (works) => {
|
||||||
for (const work of works) {
|
for (const work of works) {
|
||||||
if(work.score){
|
if(work.score){
|
||||||
work.score = "/scores/" + work.score
|
work.score = "/scores/" + work.score
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
if(work.images){
|
|
||||||
let image_ids = [];
|
|
||||||
for (const image of work.images){
|
|
||||||
image_ids.push(images.value.find(obj => {return obj.filename === image.filename})._id.$oid)
|
|
||||||
}
|
|
||||||
work.image_ids = image_ids
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
if(work.images){
|
if(work.images){
|
||||||
let gallery = [];
|
let gallery = [];
|
||||||
for (const image of work.images){
|
for (const image of work.images){
|
||||||
gallery.push({
|
gallery.push({
|
||||||
image_id: images.value.find(obj => {return obj.filename === image.filename})._id.$oid,
|
image: image.filename,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
work.gallery = gallery
|
work.gallery = gallery
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let priorityGroups = groupBy(works, work => work.priority)
|
let priorityGroups = groupBy(works, work => work.priority)
|
||||||
let groups = groupBy(priorityGroups["1"], work => new Date(work.date.$date).getFullYear())
|
let groups = groupBy(priorityGroups["1"], work => new Date(work.date).getFullYear())
|
||||||
groups = Object.keys(groups).map((year) => {
|
groups = Object.keys(groups).map((year) => {
|
||||||
return {
|
return {
|
||||||
year,
|
year,
|
||||||
works: groups[year].sort((a,b) => b.date.$date - a.date.$date)
|
works: groups[year].sort((a,b) => new Date(b.date) - new Date(a.date))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
groups.sort((a,b) => b.year - a.year)
|
groups.sort((a,b) => b.year - a.year)
|
||||||
groups.push({year: "miscellany", works: priorityGroups["2"].sort((a,b) => b.date.$date - a.date.$date)})
|
if (priorityGroups["2"]) {
|
||||||
|
groups.push({year: "miscellany", works: priorityGroups["2"].sort((a,b) => new Date(b.date) - new Date(a.date))})
|
||||||
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
//const { data: pubs } = await useFetch('https://unboundedpress.org/api/publications/_aggrs/publications?pagesize=200')
|
const { data: pubs } = await useFetch('/api/publications', {
|
||||||
//const { data: pubs } = await useFetch('https://unboundedpress.org/api/publications?sort=-entryTags.year&pagesize=200')
|
|
||||||
const { data: pubs } = await useFetch('https://unboundedpress.org/api/publications?pagesize=200', {
|
|
||||||
transform: (pubs) => {
|
transform: (pubs) => {
|
||||||
for (const pub of pubs) {
|
for (const pub of pubs) {
|
||||||
if(pub.entryTags.howpublished && !(isValidUrl(pub.entryTags.howpublished))){
|
if(pub.entryTags && pub.entryTags.howpublished && !(isValidUrl(pub.entryTags.howpublished))){
|
||||||
pub.entryTags.howpublished = "/pubs/" + pub.entryTags.howpublished
|
pub.entryTags.howpublished = "/pubs/" + pub.entryTags.howpublished
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return pubs.sort((a,b) => (a.citationKey > b.citationKey) ? -1 : ((b.citationKey > a.citationKey) ? 1 : 0))
|
return pubs.sort((a,b) => (a.citationKey > b.citationKey) ? -1 : ((b.citationKey > a.citationKey) ? 1 : 0))
|
||||||
/*
|
|
||||||
return pubs.sort((a,b) => {
|
|
||||||
let aPrime = 5000
|
|
||||||
let bPrime = 5000
|
|
||||||
if(a.entryTags.year === 'forthcoming'){aPrime = 5000} else {aPrime = a.entryTags.year}
|
|
||||||
if(b.entryTags.year === 'forthcoming'){bPrime = 5000} else {bPrime = b.entryTags.year}
|
|
||||||
return bPrime - aPrime
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: album_art } = await useFetch('https://unboundedpress.org/api/album_art.files?pagesize=200')
|
const { data: releases } = await useFetch('/api/releases', {
|
||||||
|
|
||||||
const { data: releases } = await useFetch('https://unboundedpress.org/api/releases?pagesize=200', {
|
|
||||||
//lazy: true,
|
|
||||||
//server: false,
|
|
||||||
transform: (releases) => {
|
transform: (releases) => {
|
||||||
for (const release of releases) {
|
return releases.sort((a,b) => {
|
||||||
release.album_art_id = album_art.value.find(obj => {return obj.filename === release.album_art})._id.$oid
|
const dateA = parseInt(a.date) || 0
|
||||||
}
|
const dateB = parseInt(b.date) || 0
|
||||||
return releases.sort((a,b) => b.date - a.date)
|
return dateB - dateA
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/*
|
|
||||||
watch(releases, (response)=>{
|
|
||||||
//console.log(response)
|
|
||||||
for (const item of response) {
|
|
||||||
useFetch(`https://unboundedpress.org/api/album_art.files?filter={"filename":"${item.album_art}"}`).then((response) => {
|
|
||||||
item.album_art_id = response.data.value[0]._id.$oid
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return response
|
|
||||||
|
|
||||||
}, {
|
|
||||||
//deep: true,
|
|
||||||
immediate: true
|
|
||||||
})
|
})
|
||||||
*/
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
titleTemplate: 'Michael Winter - Home / Works - Pieces, Publications, and Albums'
|
titleTemplate: 'Michael Winter - Home / Works - Pieces, Publications, and Albums'
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
28
portfolio/pages/scores/[filename].vue
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-full items-center justify-center text-center">
|
||||||
|
<embed v-if="isPdf" :src="filePath" class="w-[85%] h-[88vh]"/>
|
||||||
|
<NuxtImg v-else-if="isImage" :src="filePath" class="w-[85%]"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const filePath = computed(() => {
|
||||||
|
const filename = route.params.filename
|
||||||
|
return '/scores/' + filename
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPdf = computed(() => {
|
||||||
|
return route.params.filename?.endsWith('.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
const isImage = computed(() => {
|
||||||
|
const fn = route.params.filename || ''
|
||||||
|
return fn.endsWith('.jpg') || fn.endsWith('.jpeg') || fn.endsWith('.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter - Scores - ' + route.params.filename
|
||||||
|
})
|
||||||
|
</script>
|
||||||
239
portfolio/pages/works_list.vue
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'plain'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: resume } = await useFetch('/api/resume')
|
||||||
|
const { data: works } = await useFetch('/api/works')
|
||||||
|
const { data: events } = await useFetch('/api/events')
|
||||||
|
|
||||||
|
const worksByYear = computed(() => {
|
||||||
|
if (!works.value) return []
|
||||||
|
|
||||||
|
const grouped = {}
|
||||||
|
|
||||||
|
for (const work of works.value) {
|
||||||
|
const year = work.date ? new Date(work.date).getFullYear() : 'Unknown'
|
||||||
|
if (!grouped[year]) {
|
||||||
|
grouped[year] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const workEvents = events.value?.filter(e => {
|
||||||
|
if (!e.program) return false
|
||||||
|
return e.program.some(p => p.work?.toLowerCase().includes(work.title.toLowerCase()))
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
grouped[year].push({
|
||||||
|
...work,
|
||||||
|
location: work.instrument_tags?.[0] || '',
|
||||||
|
events: workEvents
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(grouped)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.map(year => ({
|
||||||
|
year,
|
||||||
|
works: grouped[year].sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date)) return dateStr
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
titleTemplate: 'Michael Winter'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cv-container">
|
||||||
|
<header class="cv-header">
|
||||||
|
<h1>{{ resume?.basics?.name }}</h1>
|
||||||
|
<h3>Works List with Presentation History</h3>
|
||||||
|
<p class="contact">
|
||||||
|
{{ resume?.basics?.email }} · {{ resume?.basics?.phone }} · {{ resume?.basics?.website }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<p class="intro">
|
||||||
|
A chronological performance / exhibition history, scores, and recordings are available at<br>
|
||||||
|
www.unboundedpress.org.<br>
|
||||||
|
All scores are also published or forthcoming through Frog Peak at<br>
|
||||||
|
www.frogpeak.org/fpartists/fpwinter.html.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Works by Year -->
|
||||||
|
<section v-for="yearGroup in worksByYear" :key="yearGroup.year" class="cv-section">
|
||||||
|
<h4>{{ yearGroup.year }}</h4>
|
||||||
|
|
||||||
|
<div v-for="work in yearGroup.works" :key="work.id" class="work-entry">
|
||||||
|
<div class="work-title"><em>{{ work.title }}</em></div>
|
||||||
|
<div class="work-info" v-if="work.instrument_tags">
|
||||||
|
<span v-for="(tag, idx) in work.instrument_tags" :key="tag">
|
||||||
|
{{ tag }}{{ idx < work.instrument_tags.length - 1 ? ', ' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="work-events" v-if="work.events?.length">
|
||||||
|
<div v-for="event in work.events" :key="event.id" class="event">
|
||||||
|
{{ event.venue?.name }}; {{ event.venue?.city }}, {{ event.venue?.state }} — {{ formatDate(event.start_date) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cv-container {
|
||||||
|
font-size: 12px;
|
||||||
|
width: 175mm;
|
||||||
|
margin: 40px auto;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 30px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header .contact {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-title {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-info {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-events {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
padding-left: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 16px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-container {
|
||||||
|
margin: 0;
|
||||||
|
padding: 15mm;
|
||||||
|
width: auto;
|
||||||
|
font-size: 10pt;
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h1 {
|
||||||
|
font-size: 20pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-header h3 {
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section h4 {
|
||||||
|
font-size: 10pt;
|
||||||
|
border-bottom: 1pt solid #999;
|
||||||
|
break-after: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-title {
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-info,
|
||||||
|
.work-events {
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-top: 1pt solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry,
|
||||||
|
.cv-section {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
portfolio/plugins/formkit.client.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { defaultConfig, plugin } from '@formkit/vue'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.vueApp.use(plugin, defaultConfig)
|
||||||
|
})
|
||||||
BIN
portfolio/public/album_art/CiCC_cover.jpg
Normal file
|
After Width: | Height: | Size: 832 KiB |
BIN
portfolio/public/album_art/DIY_Canons_cover.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
portfolio/public/album_art/Ostrava_cover.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
portfolio/public/album_art/Rounds_cover.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
portfolio/public/album_art/approximating_omega_cover.jpg
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
portfolio/public/album_art/lower_limit_cover.jpg
Normal file
|
After Width: | Height: | Size: 491 KiB |
BIN
portfolio/public/album_art/preliminary_thoughts_BMV.jpg
Normal file
|
After Width: | Height: | Size: 874 KiB |
BIN
portfolio/public/album_art/preliminary_thoughts_TR.jpg
Normal file
|
After Width: | Height: | Size: 1,010 KiB |
BIN
portfolio/public/album_art/single_track_cover.jpg
Normal file
|
After Width: | Height: | Size: 569 KiB |
BIN
portfolio/public/album_art/west_coast_soundings_cover.jpg
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
portfolio/public/cv.pdf
Normal file
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
BIN
portfolio/public/images/for_gregory_chaitin.jpg
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
portfolio/public/images/mbw_domino_perf.jpg
Normal file
|
After Width: | Height: | Size: 1,003 KiB |
BIN
portfolio/public/images/mbw_hundred_years_1.jpg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
portfolio/public/images/mbw_hundred_years_2.jpg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
portfolio/public/images/mbw_oaxaca_1.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
portfolio/public/images/mbw_oaxaca_2.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
portfolio/public/images/mbw_oaxaca_3.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
portfolio/public/images/mbw_ostrava_1.jpg
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
portfolio/public/images/mbw_plants_foto.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
portfolio/public/images/minor_third_abstract.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
portfolio/public/images/quieting_rooms_image_1.jpg
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
BIN
portfolio/public/images/quieting_rooms_image_2.jpg
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
BIN
portfolio/public/images/quieting_rooms_image_3.jpg
Normal file
|
After Width: | Height: | Size: 4 MiB |
BIN
portfolio/public/images/quieting_rooms_image_4.jpg
Normal file
|
After Width: | Height: | Size: 3 MiB |
BIN
portfolio/public/images/rockfall.jpg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |