+1
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
APP_NAME=portfoliojournal-og
|
||||
BUILD_DIR=bin
|
||||
PORT?=8090
|
||||
|
||||
.PHONY: build run clean install-service
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GO111MODULE=on go build -o $(BUILD_DIR)/$(APP_NAME) .
|
||||
|
||||
run:
|
||||
PORT=$(PORT) GO111MODULE=on go run .
|
||||
|
||||
clean:
|
||||
@rm -rf $(BUILD_DIR)
|
||||
|
||||
install-service: build
|
||||
@echo "Install the binary and OpenRC service using the instructions in README.md"
|
||||
@@ -0,0 +1,81 @@
|
||||
# OG Share Service
|
||||
|
||||
This service powers dynamic Open Graph share pages and images for Portfolio Journal.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `/share?data=BASE64URL` renders the HTML page with OG tags.
|
||||
- `/og?data=BASE64URL` renders the 1200x630 PNG image.
|
||||
|
||||
Payload (JSON, base64url-encoded):
|
||||
|
||||
```json
|
||||
{
|
||||
"n": "Goal name",
|
||||
"r": 12500.5,
|
||||
"g": 20000,
|
||||
"c": "USD",
|
||||
"td": "2026-12-31",
|
||||
"ec": "2026-10-15",
|
||||
"p": false
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `n` goal name
|
||||
- `r` current amount
|
||||
- `g` goal amount
|
||||
- `c` currency code (ISO 4217)
|
||||
- `td` target date (YYYY-MM-DD, optional)
|
||||
- `ec` estimated completion date (YYYY-MM-DD, optional)
|
||||
- `p` privacy mode; when true, hides the current amount
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd og-service
|
||||
GO111MODULE=on go run .
|
||||
```
|
||||
|
||||
## OpenRC (Alpine) service
|
||||
|
||||
Example steps (requires root):
|
||||
|
||||
```bash
|
||||
# Build and install the binary
|
||||
make build
|
||||
cp og-service/bin/portfoliojournal-og /usr/local/bin/portfoliojournal-og
|
||||
chmod +x /usr/local/bin/portfoliojournal-og
|
||||
|
||||
# Install the OpenRC script
|
||||
cp og-service/portfoliojournal-og.openrc /etc/init.d/portfoliojournal-og
|
||||
chmod +x /etc/init.d/portfoliojournal-og
|
||||
|
||||
# Enable and start
|
||||
rc-update add portfoliojournal-og default
|
||||
rc-service portfoliojournal-og start
|
||||
```
|
||||
|
||||
Or run the helper script (requires root):
|
||||
|
||||
```bash
|
||||
sudo sh og-service/install-openrc.sh
|
||||
```
|
||||
|
||||
## NGINX example
|
||||
|
||||
```nginx
|
||||
location /share {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
|
||||
location /og {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
module portfoliojournal.app/ogservice
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/fogleman/gg v1.3.0
|
||||
golang.org/x/image v0.21.0
|
||||
golang.org/x/text v0.17.0
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
APP_NAME="portfoliojournal-og"
|
||||
SERVICE_FILE="/etc/init.d/${APP_NAME}"
|
||||
BIN_TARGET="/usr/local/bin/${APP_NAME}"
|
||||
|
||||
echo "Building ${APP_NAME}..."
|
||||
make -C "$(dirname "$0")" build
|
||||
|
||||
echo "Installing binary to ${BIN_TARGET}..."
|
||||
cp "$(dirname "$0")/bin/${APP_NAME}" "${BIN_TARGET}"
|
||||
chmod +x "${BIN_TARGET}"
|
||||
|
||||
echo "Installing OpenRC service to ${SERVICE_FILE}..."
|
||||
cp "$(dirname "$0")/${APP_NAME}.openrc" "${SERVICE_FILE}"
|
||||
chmod +x "${SERVICE_FILE}"
|
||||
|
||||
echo "Enabling service..."
|
||||
rc-update add "${APP_NAME}" default
|
||||
|
||||
echo "Starting service..."
|
||||
rc-service "${APP_NAME}" start
|
||||
|
||||
echo "Done."
|
||||
@@ -0,0 +1,626 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/gofont/gobold"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/text/currency"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
const (
|
||||
imageWidth = 1200
|
||||
imageHeight = 630
|
||||
)
|
||||
|
||||
type shareData struct {
|
||||
Name string `json:"n"`
|
||||
RealAmount json.Number `json:"r"`
|
||||
GoalAmount json.Number `json:"g"`
|
||||
Currency string `json:"c"`
|
||||
TargetDate string `json:"td"`
|
||||
EstimateCompletion string `json:"ec"`
|
||||
PrivateMode bool `json:"p"`
|
||||
}
|
||||
|
||||
type shareView struct {
|
||||
Title string
|
||||
Description string
|
||||
OGImageURL string
|
||||
ShareURL string
|
||||
GoalName string
|
||||
CurrentAmount string
|
||||
TargetAmount string
|
||||
PercentText string
|
||||
ProgressPercent float64
|
||||
TargetDateLabel string
|
||||
EstimateDateLabel string
|
||||
PrivateMode bool
|
||||
FormattedGoalAmount string
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8090"
|
||||
}
|
||||
|
||||
http.HandleFunc("/share", shareHandler)
|
||||
http.HandleFunc("/og", ogHandler)
|
||||
|
||||
log.Printf("og-service listening on :%s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||
}
|
||||
|
||||
func shareHandler(w http.ResponseWriter, r *http.Request) {
|
||||
dataParam := r.URL.Query().Get("data")
|
||||
if dataParam == "" {
|
||||
http.Error(w, "missing data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := decodeData(dataParam)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
view := buildShareView(payload, r, dataParam)
|
||||
html := renderShareHTML(view)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func ogHandler(w http.ResponseWriter, r *http.Request) {
|
||||
dataParam := r.URL.Query().Get("data")
|
||||
if dataParam == "" {
|
||||
http.Error(w, "missing data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := decodeData(dataParam)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pngBytes, err := renderOGImage(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to render image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
_, _ = w.Write(pngBytes)
|
||||
}
|
||||
|
||||
func decodeData(param string) (shareData, error) {
|
||||
var data shareData
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(param)
|
||||
if err != nil {
|
||||
decoded, err = base64.URLEncoding.DecodeString(param)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(decoded))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
data.Name = strings.TrimSpace(data.Name)
|
||||
data.Currency = strings.ToUpper(strings.TrimSpace(data.Currency))
|
||||
if data.Currency == "" {
|
||||
data.Currency = "USD"
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func buildShareView(data shareData, r *http.Request, rawParam string) shareView {
|
||||
goalAmount, _ := numberToFloat(data.GoalAmount)
|
||||
realAmount, _ := numberToFloat(data.RealAmount)
|
||||
|
||||
percent := 0.0
|
||||
if goalAmount > 0 {
|
||||
percent = (realAmount / goalAmount) * 100
|
||||
}
|
||||
|
||||
percentText := "Progress unavailable"
|
||||
if goalAmount > 0 {
|
||||
percentText = fmt.Sprintf("%.0f%% complete", percent)
|
||||
}
|
||||
|
||||
currencyTag := localeForCurrency(data.Currency)
|
||||
formattedGoal := formatCurrency(goalAmount, data.Currency, currencyTag)
|
||||
formattedReal := formatCurrency(realAmount, data.Currency, currencyTag)
|
||||
|
||||
currentAmount := formattedReal
|
||||
if data.PrivateMode {
|
||||
currentAmount = "Hidden"
|
||||
}
|
||||
|
||||
goalName := data.Name
|
||||
if goalName == "" {
|
||||
goalName = "Investment Goal"
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("%s toward %s", percentText, formattedGoal)
|
||||
if data.PrivateMode {
|
||||
description = fmt.Sprintf("Goal progress: %s", percentText)
|
||||
}
|
||||
|
||||
baseURL := buildBaseURL(r)
|
||||
shareURL := fmt.Sprintf("%s/share?data=%s", baseURL, url.QueryEscape(rawParam))
|
||||
ogImageURL := fmt.Sprintf("%s/og?data=%s", baseURL, url.QueryEscape(rawParam))
|
||||
|
||||
return shareView{
|
||||
Title: fmt.Sprintf("Portfolio Journal — %s", goalName),
|
||||
Description: description,
|
||||
OGImageURL: ogImageURL,
|
||||
ShareURL: shareURL,
|
||||
GoalName: goalName,
|
||||
CurrentAmount: currentAmount,
|
||||
TargetAmount: formattedGoal,
|
||||
PercentText: percentText,
|
||||
ProgressPercent: percent,
|
||||
TargetDateLabel: formatDateLabel("Target", data.TargetDate),
|
||||
EstimateDateLabel: formatDateLabel("Est. completion", data.EstimateCompletion),
|
||||
PrivateMode: data.PrivateMode,
|
||||
FormattedGoalAmount: formattedGoal,
|
||||
}
|
||||
}
|
||||
|
||||
func renderShareHTML(view shareView) string {
|
||||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>` + escapeHTML(view.Title) + `</title>
|
||||
|
||||
<meta name="title" content="` + escapeHTML(view.Title) + `">
|
||||
<meta name="description" content="` + escapeHTML(view.Description) + `">
|
||||
<meta name="author" content="Portfolio Journal">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="` + escapeHTML(view.ShareURL) + `">
|
||||
<meta property="og:title" content="` + escapeHTML(view.Title) + `">
|
||||
<meta property="og:description" content="` + escapeHTML(view.Description) + `">
|
||||
<meta property="og:image" content="` + escapeHTML(view.OGImageURL) + `">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Portfolio Journal">
|
||||
<meta property="og:locale" content="en_US">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="` + escapeHTML(view.ShareURL) + `">
|
||||
<meta name="twitter:title" content="` + escapeHTML(view.Title) + `">
|
||||
<meta name="twitter:description" content="` + escapeHTML(view.Description) + `">
|
||||
<meta name="twitter:image" content="` + escapeHTML(view.OGImageURL) + `">
|
||||
|
||||
<meta name="apple-itunes-app" content="app-id=6744983373">
|
||||
<link rel="canonical" href="` + escapeHTML(view.ShareURL) + `">
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.card {
|
||||
max-width: 620px;
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 24px;
|
||||
padding: 40px;
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.25);
|
||||
}
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.amounts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.amounts span {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.amounts strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.progress {
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.progress .bar {
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
width: ` + fmt.Sprintf("%.2f", math.Max(0, math.Min(100, view.ProgressPercent))) + `%;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 18px;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cta {
|
||||
margin-top: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
color: #5c5bd4;
|
||||
padding: 12px 20px;
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="eyebrow">Portfolio Journal</div>
|
||||
<h1>` + escapeHTML(view.GoalName) + `</h1>
|
||||
<div class="amounts">
|
||||
<div>
|
||||
<span>Current amount</span>
|
||||
<strong>` + escapeHTML(view.CurrentAmount) + `</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Goal amount</span>
|
||||
<strong>` + escapeHTML(view.TargetAmount) + `</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>` + escapeHTML(view.PercentText) + `</div>
|
||||
<div class="progress"><div class="bar"></div></div>
|
||||
<div class="meta">` + escapeHTML(joinLabels(view.TargetDateLabel, view.EstimateDateLabel)) + `</div>
|
||||
<a class="cta" href="https://apps.apple.com/app/portfolio-journal/id6744983373">Download on the App Store</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return htmlTemplate
|
||||
}
|
||||
|
||||
func renderOGImage(data shareData) ([]byte, error) {
|
||||
goalAmount, _ := numberToFloat(data.GoalAmount)
|
||||
realAmount, _ := numberToFloat(data.RealAmount)
|
||||
percent := 0.0
|
||||
if goalAmount > 0 {
|
||||
percent = (realAmount / goalAmount) * 100
|
||||
}
|
||||
|
||||
currencyTag := localeForCurrency(data.Currency)
|
||||
formattedGoal := formatCurrency(goalAmount, data.Currency, currencyTag)
|
||||
formattedReal := formatCurrency(realAmount, data.Currency, currencyTag)
|
||||
if data.PrivateMode {
|
||||
formattedReal = "Hidden"
|
||||
}
|
||||
|
||||
goalName := data.Name
|
||||
if goalName == "" {
|
||||
goalName = "Investment Goal"
|
||||
}
|
||||
|
||||
dc := gg.NewContext(imageWidth, imageHeight)
|
||||
|
||||
grad := gg.NewLinearGradient(0, 0, imageWidth, imageHeight)
|
||||
grad.AddColorStop(0, color.RGBA{0x66, 0x7e, 0xea, 0xff})
|
||||
grad.AddColorStop(1, color.RGBA{0x76, 0x4b, 0xa2, 0xff})
|
||||
|
||||
dc.SetFillStyle(grad)
|
||||
dc.DrawRectangle(0, 0, imageWidth, imageHeight)
|
||||
dc.Fill()
|
||||
|
||||
marginX := 80.0
|
||||
marginY := 80.0
|
||||
|
||||
// App icon placeholder
|
||||
dc.SetRGBA(1, 1, 1, 0.2)
|
||||
dc.DrawRoundedRectangle(marginX, marginY, 96, 96, 22)
|
||||
dc.Fill()
|
||||
|
||||
iconFace, err := loadFontFace(38, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc.SetFontFace(iconFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.9)
|
||||
dc.DrawStringAnchored("PJ", marginX+48, marginY+52, 0.5, 0.5)
|
||||
|
||||
labelFace, err := loadFontFace(20, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc.SetFontFace(labelFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.8)
|
||||
dc.DrawString("Portfolio Journal", marginX+120, marginY+34)
|
||||
|
||||
titleFace, err := loadFontFace(54, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc.SetFontFace(titleFace)
|
||||
dc.SetRGBA(1, 1, 1, 1)
|
||||
goalName = fitText(dc, goalName, imageWidth-160)
|
||||
dc.DrawStringWrapped(goalName, marginX, marginY+130, 0, 0, imageWidth-160, 1.3, gg.AlignLeft)
|
||||
|
||||
valueFace, err := loadFontFace(36, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labelSmallFace, err := loadFontFace(18, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dc.SetFontFace(labelSmallFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.75)
|
||||
dc.DrawString("Current", marginX, marginY+260)
|
||||
dc.DrawString("Goal", marginX+420, marginY+260)
|
||||
|
||||
dc.SetFontFace(valueFace)
|
||||
dc.SetRGBA(1, 1, 1, 1)
|
||||
dc.DrawString(formattedReal, marginX, marginY+305)
|
||||
dc.DrawString(formattedGoal, marginX+420, marginY+305)
|
||||
|
||||
percentText := "Progress unavailable"
|
||||
if goalAmount > 0 {
|
||||
percentText = fmt.Sprintf("%.0f%% complete", percent)
|
||||
}
|
||||
if data.PrivateMode {
|
||||
percentText = strings.ReplaceAll(percentText, "complete", "of goal")
|
||||
}
|
||||
|
||||
dc.SetFontFace(labelSmallFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.85)
|
||||
dc.DrawString(percentText, marginX, marginY+360)
|
||||
|
||||
barWidth := 720.0
|
||||
barHeight := 18.0
|
||||
barX := marginX
|
||||
barY := marginY + 380
|
||||
progress := math.Max(0, math.Min(100, percent)) / 100
|
||||
|
||||
dc.SetRGBA(1, 1, 1, 0.25)
|
||||
dc.DrawRoundedRectangle(barX, barY, barWidth, barHeight, 9)
|
||||
dc.Fill()
|
||||
|
||||
dc.SetRGBA(1, 1, 1, 0.9)
|
||||
dc.DrawRoundedRectangle(barX, barY, barWidth*progress, barHeight, 9)
|
||||
dc.Fill()
|
||||
|
||||
meta := joinLabels(formatDateLabel("Target", data.TargetDate), formatDateLabel("Est. completion", data.EstimateCompletion))
|
||||
if meta != "" {
|
||||
dc.SetFontFace(labelSmallFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.7)
|
||||
dc.DrawString(meta, marginX, marginY+430)
|
||||
}
|
||||
|
||||
ctaFace, err := loadFontFace(20, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc.SetFontFace(ctaFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.9)
|
||||
dc.DrawString("Available on the App Store", marginX, imageHeight-70)
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err := dc.EncodePNG(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func numberToFloat(n json.Number) (float64, bool) {
|
||||
if n.String() == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := n.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func formatCurrency(amount float64, code string, tag language.Tag) string {
|
||||
unit, err := currency.ParseISO(code)
|
||||
if err != nil {
|
||||
unit = currency.USD
|
||||
}
|
||||
printer := message.NewPrinter(tag)
|
||||
return printer.Sprintf("%v", currency.Amount(amount, unit))
|
||||
}
|
||||
|
||||
func localeForCurrency(code string) language.Tag {
|
||||
switch code {
|
||||
case "EUR":
|
||||
return language.MustParse("es-ES")
|
||||
case "GBP":
|
||||
return language.MustParse("en-GB")
|
||||
case "JPY":
|
||||
return language.MustParse("ja-JP")
|
||||
case "CNY":
|
||||
return language.MustParse("zh-CN")
|
||||
case "HKD":
|
||||
return language.MustParse("zh-HK")
|
||||
case "KRW":
|
||||
return language.MustParse("ko-KR")
|
||||
case "INR":
|
||||
return language.MustParse("en-IN")
|
||||
case "BRL":
|
||||
return language.MustParse("pt-BR")
|
||||
case "MXN":
|
||||
return language.MustParse("es-MX")
|
||||
case "ARS":
|
||||
return language.MustParse("es-AR")
|
||||
case "CLP":
|
||||
return language.MustParse("es-CL")
|
||||
case "COP":
|
||||
return language.MustParse("es-CO")
|
||||
case "CAD":
|
||||
return language.MustParse("en-CA")
|
||||
case "AUD":
|
||||
return language.MustParse("en-AU")
|
||||
case "NZD":
|
||||
return language.MustParse("en-NZ")
|
||||
case "CHF":
|
||||
return language.MustParse("de-CH")
|
||||
case "SEK":
|
||||
return language.MustParse("sv-SE")
|
||||
case "NOK":
|
||||
return language.MustParse("nb-NO")
|
||||
case "DKK":
|
||||
return language.MustParse("da-DK")
|
||||
case "PLN":
|
||||
return language.MustParse("pl-PL")
|
||||
case "CZK":
|
||||
return language.MustParse("cs-CZ")
|
||||
case "HUF":
|
||||
return language.MustParse("hu-HU")
|
||||
case "TRY":
|
||||
return language.MustParse("tr-TR")
|
||||
case "ZAR":
|
||||
return language.MustParse("en-ZA")
|
||||
case "AED":
|
||||
return language.MustParse("ar-AE")
|
||||
default:
|
||||
return language.MustParse("en-US")
|
||||
}
|
||||
}
|
||||
|
||||
func formatDateLabel(prefix, dateString string) string {
|
||||
if dateString == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := time.Parse("2006-01-02", dateString)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", prefix, parsed.Format("Jan 2, 2006"))
|
||||
}
|
||||
|
||||
func joinLabels(primary, secondary string) string {
|
||||
if primary == "" && secondary == "" {
|
||||
return ""
|
||||
}
|
||||
if primary == "" {
|
||||
return secondary
|
||||
}
|
||||
if secondary == "" {
|
||||
return primary
|
||||
}
|
||||
return primary + " • " + secondary
|
||||
}
|
||||
|
||||
func buildBaseURL(r *http.Request) string {
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
proto = "https"
|
||||
}
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", proto, host)
|
||||
}
|
||||
|
||||
func escapeHTML(input string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
return replacer.Replace(input)
|
||||
}
|
||||
|
||||
func loadFontFace(size float64, bold bool) (font.Face, error) {
|
||||
var fontBytes []byte
|
||||
if bold {
|
||||
fontBytes = gobold.TTF
|
||||
} else {
|
||||
fontBytes = goregular.TTF
|
||||
}
|
||||
parsed, err := opentype.Parse(fontBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{
|
||||
Size: size,
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return face, nil
|
||||
}
|
||||
|
||||
func fitText(dc *gg.Context, text string, maxWidth float64) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if width, _ := dc.MeasureString(trimmed); width <= maxWidth {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
runes := []rune(trimmed)
|
||||
for len(runes) > 0 {
|
||||
candidate := string(runes) + "…"
|
||||
if width, _ := dc.MeasureString(candidate); width <= maxWidth {
|
||||
return candidate
|
||||
}
|
||||
runes = runes[:len(runes)-1]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name="portfoliojournal-og"
|
||||
description="Portfolio Journal Open Graph share service"
|
||||
|
||||
command="/usr/local/bin/portfoliojournal-og"
|
||||
command_args=""
|
||||
command_background="yes"
|
||||
output_log="/var/log/portfoliojournal-og.log"
|
||||
error_log="/var/log/portfoliojournal-og.err"
|
||||
pidfile="/run/portfoliojournal-og.pid"
|
||||
|
||||
start_pre() {
|
||||
checkpath --file --mode 0644 --owner root:root "$output_log"
|
||||
checkpath --file --mode 0644 --owner root:root "$error_log"
|
||||
}
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after firewall
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Open Graph Image Specification
|
||||
|
||||
## Required Image: `og-share-card.png`
|
||||
|
||||
Create this image and place it at: `https://portfoliojournal.app/images/og-share-card.png`
|
||||
|
||||
### Dimensions
|
||||
- **Size:** 1200 x 630 pixels (Facebook/LinkedIn optimal)
|
||||
- **Format:** PNG or JPG
|
||||
- **File size:** Under 1MB recommended
|
||||
|
||||
### Design Recommendations
|
||||
|
||||
```
|
||||
+----------------------------------------------------------+
|
||||
| |
|
||||
| [App Icon] |
|
||||
| |
|
||||
| Portfolio Journal |
|
||||
| ───────────────── |
|
||||
| |
|
||||
| Track your investment goals |
|
||||
| and celebrate your wins. |
|
||||
| |
|
||||
| [Progress bar illustration] |
|
||||
| |
|
||||
| ★ Goal Tracking ★ Smart Predictions |
|
||||
| ★ Portfolio Analytics ★ Privacy Focused |
|
||||
| |
|
||||
| [App Store Badge] |
|
||||
| |
|
||||
+----------------------------------------------------------+
|
||||
```
|
||||
|
||||
### Color Scheme
|
||||
- **Background:** Gradient from #667eea to #764ba2 (match your app branding)
|
||||
- **Text:** White
|
||||
- **Accent:** Your app's accent color
|
||||
|
||||
### Elements to Include
|
||||
1. App icon (prominent, top-left or center)
|
||||
2. App name "Portfolio Journal"
|
||||
3. Tagline or value proposition
|
||||
4. Visual element (progress bar, chart icon, or screenshot)
|
||||
5. Feature highlights
|
||||
6. App Store badge (optional)
|
||||
|
||||
### Tools to Create
|
||||
- Figma (free template available)
|
||||
- Canva
|
||||
- Adobe Illustrator/Photoshop
|
||||
|
||||
## Testing Your OG Tags
|
||||
|
||||
After deploying, test with these tools:
|
||||
- **Facebook:** https://developers.facebook.com/tools/debug/
|
||||
- **Twitter:** https://cards-dev.twitter.com/validator
|
||||
- **LinkedIn:** https://www.linkedin.com/post-inspector/
|
||||
- **General:** https://www.opengraph.xyz/
|
||||
|
||||
## Files to Deploy
|
||||
|
||||
1. `/share/index.html` (or configure your server to serve `share.html` at `/share`)
|
||||
2. `/images/og-share-card.png`
|
||||
3. `/images/app-icon.png` (your app icon, 120x120 or larger)
|
||||
|
||||
## Apple Smart App Banner
|
||||
|
||||
The HTML includes this meta tag:
|
||||
```html
|
||||
<meta name="apple-itunes-app" content="app-id=6744983373">
|
||||
```
|
||||
|
||||
This shows a smart banner on iOS Safari prompting users to download your app.
|
||||
@@ -0,0 +1,203 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Portfolio Journal - Track Your Investment Goals</title>
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta name="title" content="Portfolio Journal - Track Your Investment Goals">
|
||||
<meta name="description" content="Set financial goals, track your progress, and celebrate your investment milestones. Available on the App Store.">
|
||||
<meta name="keywords" content="investment tracker, portfolio tracker, financial goals, investment app, iOS app, wealth tracking">
|
||||
<meta name="author" content="Portfolio Journal">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://portfoliojournal.app/share">
|
||||
<meta property="og:title" content="Portfolio Journal - Track Your Investment Goals">
|
||||
<meta property="og:description" content="Set financial goals, track your progress, and celebrate your investment milestones. Download now on the App Store.">
|
||||
<meta property="og:image" content="https://portfoliojournal.app/images/og-share-card.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Portfolio Journal">
|
||||
<meta property="og:locale" content="en_US">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://portfoliojournal.app/share">
|
||||
<meta name="twitter:title" content="Portfolio Journal - Track Your Investment Goals">
|
||||
<meta name="twitter:description" content="Set financial goals, track your progress, and celebrate your investment milestones. Download now on the App Store.">
|
||||
<meta name="twitter:image" content="https://portfoliojournal.app/images/og-share-card.png">
|
||||
|
||||
<!-- Apple Smart App Banner -->
|
||||
<meta name="apple-itunes-app" content="app-id=6744983373">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href="https://portfoliojournal.app/share">
|
||||
|
||||
<!-- Schema.org structured data for SEO/AEO -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "MobileApplication",
|
||||
"name": "Portfolio Journal",
|
||||
"operatingSystem": "iOS",
|
||||
"applicationCategory": "FinanceApplication",
|
||||
"description": "Track your investment portfolio, set financial goals, and monitor your progress with beautiful charts and predictions.",
|
||||
"url": "https://portfoliojournal.app",
|
||||
"downloadUrl": "https://apps.apple.com/app/portfolio-journal/id6744983373",
|
||||
"screenshot": "https://portfoliojournal.app/images/og-share-card.png",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "Portfolio Journal"
|
||||
},
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5",
|
||||
"ratingCount": "1"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
color: white;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.feature {
|
||||
background: rgba(255,255,255,0.15);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
padding: 16px 32px;
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.download-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Replace with your actual app icon -->
|
||||
<img src="/images/app-icon.png" alt="Portfolio Journal" class="app-icon">
|
||||
|
||||
<h1>Portfolio Journal</h1>
|
||||
<p class="tagline">Track your investment goals and celebrate your financial milestones.</p>
|
||||
|
||||
<div class="features">
|
||||
<span class="feature">Goal Tracking</span>
|
||||
<span class="feature">Portfolio Analytics</span>
|
||||
<span class="feature">Smart Predictions</span>
|
||||
<span class="feature">Privacy Focused</span>
|
||||
</div>
|
||||
|
||||
<a href="https://apps.apple.com/app/portfolio-journal/id6744983373" class="download-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
||||
</svg>
|
||||
Download on App Store
|
||||
</a>
|
||||
|
||||
<p class="footer">
|
||||
<a href="https://portfoliojournal.app">portfoliojournal.app</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-redirect to App Store after 3 seconds (optional) -->
|
||||
<script>
|
||||
// Uncomment to enable auto-redirect
|
||||
// setTimeout(function() {
|
||||
// window.location.href = 'https://apps.apple.com/app/portfolio-journal/id6744983373';
|
||||
// }, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user