Compare commits
13 Commits
05376bff2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88218c7798 | ||
|
|
95609dc0cf | ||
|
|
87e947ee95 | ||
|
|
32e9282349 | ||
|
|
d5d925d534 | ||
|
|
638e1d5b20 | ||
|
|
e8e2daccfe | ||
|
|
6c1543bf84 | ||
|
|
c467b47559 | ||
|
|
efc013f498 | ||
|
|
3d4a753be7 | ||
|
|
a758a350e9 | ||
|
|
9be883cbaf |
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
71
README.md
@@ -1,25 +1,62 @@
|
||||
# Dewemoji Rebuild Documentation (Corrected Sources)
|
||||
# Dewemoji Documentation
|
||||
|
||||
This documentation now uses the corrected legacy/source folders:
|
||||
This repository had many overlapping Markdown files. On **2026-03-12**, docs were consolidated into a smaller, maintainable set.
|
||||
|
||||
- `../dewemoji-api` — backend (and currently also contains working website/API pages)
|
||||
- `../dewemoji-chrome-ext` — Chrome extension (active)
|
||||
- `../dewemoji-site` — website repo target (currently scaffold/empty files)
|
||||
## Current docs (authoritative)
|
||||
|
||||
## Documents
|
||||
1. `README.md` (this index + quickstart)
|
||||
2. `api-how-it-works.md` (API contract + smoke tests)
|
||||
3. `deployment-live-walkthrough.md` (operations runbook: env, deploy, staging sync, DB access, billing runtime checks)
|
||||
4. `production-env.md` (minimal production env template)
|
||||
5. `dewemoji-direction-2026.md` (product direction + implementation priorities)
|
||||
6. `admin-dashboard-plan.md` (admin/user dashboard scope)
|
||||
7. `dewemoji-apk-companion-build-walkthrough.md` (APK build, release, versioning)
|
||||
8. `dewemoji-extension-notes.md` (active extension backlog)
|
||||
|
||||
1. `legacy-system-audit.md`
|
||||
2. `legacy-api-spec.md`
|
||||
3. `legacy-credentials-and-config.md`
|
||||
4. `rebuild-progress.md`
|
||||
5. `phase-1-foundation.md`
|
||||
6. `phase-2-api.md`
|
||||
7. `phase-3-website.md`
|
||||
## Local run quickstart
|
||||
|
||||
## Note
|
||||
### Option A: Docker Compose
|
||||
|
||||
Previous docs referenced `dewemoji-frontend` and `dewemoji-chrome`; those references are now replaced with `dewemoji-chrome-ext` and `dewemoji-site` based on your correction.
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Rebuild app
|
||||
App URL: `http://127.0.0.1:8000`
|
||||
|
||||
- New app scaffold lives in `app/` (Laravel + NativePHP Desktop).
|
||||
Stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Option B: Native Laravel
|
||||
|
||||
```bash
|
||||
cd app
|
||||
cp .env.example .env
|
||||
composer install
|
||||
npm install
|
||||
php artisan key:generate
|
||||
php artisan migrate
|
||||
php artisan serve --host=127.0.0.1 --port=8000
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Basic sanity checks
|
||||
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/v1/health"
|
||||
curl -s "http://127.0.0.1:8000/v1/emojis?limit=3"
|
||||
```
|
||||
|
||||
## Documentation policy
|
||||
|
||||
- Keep docs practical and current.
|
||||
- Merge instead of duplicating runbooks.
|
||||
- If a doc becomes historical, summarize key points into an active doc, then remove it.
|
||||
|
||||
@@ -1,61 +1,109 @@
|
||||
# Admin Dashboard Plan (Power Control)
|
||||
# Dashboard Plan (Admin + User)
|
||||
|
||||
This is the internal control panel used to keep Dewemoji **safe, clean, and reliable**.
|
||||
This file is the single dashboard planning and operations reference.
|
||||
|
||||
## Purpose
|
||||
## 1) Dashboard objectives
|
||||
|
||||
- Moderate public keywords and votes.
|
||||
- Manage licenses and activations.
|
||||
- Monitor system health and data pipelines.
|
||||
1. Operate Dewemoji safely (subscriptions, webhooks, settings).
|
||||
2. Manage Personal plan lifecycle and pricing.
|
||||
3. Give Personal users fast keyword/API key management.
|
||||
|
||||
## Phase 1 (MVP — must‑have)
|
||||
## 2) Current admin routes (implemented)
|
||||
|
||||
### 1) Public keyword moderation
|
||||
- View **public_pending** keyword queue.
|
||||
- Approve / reject / block keyword.
|
||||
- See emoji, language, proposer, vote counts.
|
||||
- `GET /dashboard/admin/analytics`
|
||||
- `GET /dashboard/admin/users`
|
||||
- `POST /dashboard/admin/users/tier`
|
||||
- `GET /dashboard/admin/subscriptions`
|
||||
- `POST /dashboard/admin/subscriptions/grant`
|
||||
- `POST /dashboard/admin/subscriptions/revoke`
|
||||
- `GET /dashboard/admin/pricing`
|
||||
- `POST /dashboard/admin/pricing/update`
|
||||
- `POST /dashboard/admin/pricing/reset`
|
||||
- `GET /dashboard/admin/webhooks`
|
||||
- `POST /dashboard/admin/webhooks/{id}/replay`
|
||||
- `GET /dashboard/admin/settings`
|
||||
- `POST /dashboard/admin/settings/update`
|
||||
|
||||
### 2) Abuse controls
|
||||
- Blocklist terms.
|
||||
- Quick “hide” keyword from public search.
|
||||
- Soft‑ban repeated abusive accounts.
|
||||
## 3) Admin module scope
|
||||
|
||||
### 3) License management
|
||||
- Lookup by license key.
|
||||
- See activations (device_id, product).
|
||||
- Revoke activation or whole license.
|
||||
### Analytics
|
||||
|
||||
### 4) System health
|
||||
- Last JSON rebuild time.
|
||||
- Dataset counts (emojis, keywords).
|
||||
- API usage summary (daily).
|
||||
- user/subscription/payment/webhook totals
|
||||
- recent webhook and billing activity
|
||||
|
||||
### 5) Price control (Personal plan)
|
||||
- Set IDR pricing for Monthly / Annual / Lifetime.
|
||||
- Optional USD display override (approx only).
|
||||
- Toggle payment rails (PayPal / QRIS).
|
||||
- Effective date + change log (who changed, when).
|
||||
### Users
|
||||
|
||||
## Phase 2 (Nice‑to‑have)
|
||||
- filter by tier/role/search
|
||||
- controlled tier update operations
|
||||
|
||||
- AI moderation log viewer.
|
||||
- Turnstile failure analytics.
|
||||
- Contributor leaderboard.
|
||||
- Email queue status.
|
||||
- Scheduled job history.
|
||||
- Pricing experiment history.
|
||||
### Subscriptions and payments
|
||||
|
||||
## Suggested navigation
|
||||
- grant/revoke workflows
|
||||
- provider/status visibility (`paypal`, `qris/pakasir`, `admin`)
|
||||
- pending/paid/failed/expired status clarity
|
||||
|
||||
- **Dashboard** (health, quick stats)
|
||||
- **Keywords** (pending + public)
|
||||
- **Licenses**
|
||||
- **Users**
|
||||
- **System** (jobs, JSON rebuild, logs)
|
||||
### Webhooks
|
||||
|
||||
## Access control
|
||||
- recent events list
|
||||
- replay support
|
||||
- idempotency-safe processing expectations
|
||||
|
||||
- Admin login uses **magic‑link/OTP session** + **role=admin** check.
|
||||
- `X-Admin-Token` is **dev/internal only** (disable in prod).
|
||||
- No IP allowlist required (dynamic ISP friendly).
|
||||
- Log all actions (who approved / rejected / revoked).
|
||||
### Pricing
|
||||
|
||||
- edit plan values and provider toggles
|
||||
- preserve change log snapshots for auditability
|
||||
|
||||
### Settings
|
||||
|
||||
- maintenance flag
|
||||
- public access guard values (`public_enforce`, origins, extension IDs, hourly limit)
|
||||
|
||||
## 4) User dashboard scope
|
||||
|
||||
### User states
|
||||
|
||||
1. visitor: no dashboard
|
||||
2. free logged-in: dashboard access with locked personalization areas
|
||||
3. personal: full access
|
||||
|
||||
### User modules
|
||||
|
||||
- Overview (summary metrics)
|
||||
- My Keywords (CRUD, filter, import/export)
|
||||
- API Keys (create/revoke)
|
||||
- Billing (current plan + payment history + resume pending)
|
||||
- Preferences (theme/tone; optional expansion)
|
||||
|
||||
### UX priority
|
||||
|
||||
- quick-add keywords on emoji detail pages (primary)
|
||||
- dashboard bulk management (secondary)
|
||||
|
||||
## 5) Billing integration expectations
|
||||
|
||||
Target data model coverage:
|
||||
|
||||
- `orders`
|
||||
- `payments`
|
||||
- `subscriptions`
|
||||
- `webhook_events`
|
||||
|
||||
Required runtime behaviors:
|
||||
|
||||
1. webhook-confirmed status transitions
|
||||
2. pending checkout cooldown enforcement
|
||||
3. resume pending checkout from billing page
|
||||
4. safe downgrade when no active subscription remains
|
||||
|
||||
## 6) Access and security
|
||||
|
||||
- Admin access is role-based session auth (`users.role = admin`).
|
||||
- `X-Admin-Token` should remain internal/dev usage only.
|
||||
- Log sensitive actions (tier changes, pricing updates, manual grants/revokes, webhook replays).
|
||||
|
||||
## 7) Implementation priorities
|
||||
|
||||
1. strengthen payments/subscriptions observability
|
||||
2. finalize user dashboard CRUD ergonomics
|
||||
3. enforce non-destructive confirmations for sensitive admin actions
|
||||
4. add pagination/filter/sorting consistency across large admin lists
|
||||
|
||||
@@ -1,355 +1,256 @@
|
||||
# Dewemoji API — How It Works
|
||||
# Dewemoji API — Reference and Smoke Test
|
||||
|
||||
This document explains the current API surface in the rebuild app, including filters, headers, and request/response shapes.
|
||||
This is the single API documentation source for the current Laravel rebuild.
|
||||
|
||||
## Base URLs
|
||||
|
||||
- Local: `http://127.0.0.1:8000/v1`
|
||||
- Staging: `https://dewemoji.backoffice.biz.id/v1`
|
||||
|
||||
## Auth & headers
|
||||
Convenience:
|
||||
|
||||
### License key (optional for free, required for Pro)
|
||||
You can send a license key in either header:
|
||||
```bash
|
||||
BASE=http://127.0.0.1:8000/v1
|
||||
# BASE=https://dewemoji.backoffice.biz.id/v1
|
||||
```
|
||||
|
||||
- `Authorization: Bearer YOUR_LICENSE_KEY` (recommended)
|
||||
- `X-License-Key: YOUR_LICENSE_KEY` (also supported)
|
||||
## Auth and request headers
|
||||
|
||||
### Optional headers
|
||||
- `X-Account-Id`: Optional usage association.
|
||||
- `X-Dewemoji-Frontend`: Optional frontend identifier (string).
|
||||
License-style auth (legacy-compatible):
|
||||
|
||||
- `Authorization: Bearer YOUR_LICENSE_KEY` (preferred)
|
||||
- `X-License-Key: YOUR_LICENSE_KEY` (supported)
|
||||
|
||||
Optional headers:
|
||||
|
||||
- `X-Account-Id`
|
||||
- `X-Dewemoji-Frontend`
|
||||
|
||||
Common response headers:
|
||||
|
||||
### Response headers you’ll see
|
||||
- `X-Dewemoji-Tier`: `free` or `pro`
|
||||
- `X-Dewemoji-Plan`: `free` or `pro`
|
||||
- Rate‑limit headers on free tier (page=1 only):
|
||||
- `ETag`
|
||||
- `Cache-Control`
|
||||
- Rate-limit headers on free tier page 1 requests:
|
||||
- `X-RateLimit-Limit`
|
||||
- `X-RateLimit-Remaining`
|
||||
- `X-RateLimit-Reset`
|
||||
- Caching:
|
||||
- `ETag`
|
||||
- `Cache-Control`
|
||||
|
||||
## Endpoints
|
||||
## Endpoint map
|
||||
|
||||
### GET `/emojis`
|
||||
Search emojis with filters.
|
||||
Public/search:
|
||||
|
||||
- `GET /emojis`
|
||||
- `GET /emoji/{slug}` or `GET /emoji?slug=...`
|
||||
- `GET /categories`
|
||||
- `GET /health`
|
||||
|
||||
License and access lifecycle:
|
||||
|
||||
- `POST /license/verify`
|
||||
- `POST /license/activate`
|
||||
- `POST /license/deactivate`
|
||||
|
||||
Extension verification:
|
||||
|
||||
- `POST /extension/verify`
|
||||
- `GET /extension/search`
|
||||
|
||||
Metrics/internal:
|
||||
|
||||
- `GET /metrics-lite`
|
||||
- `GET /metrics`
|
||||
|
||||
Admin token endpoints (`X-Admin-Token` required):
|
||||
|
||||
- `GET /admin/settings`
|
||||
- `POST /admin/settings`
|
||||
- `GET /admin/subscriptions`
|
||||
- `POST /admin/subscription/grant`
|
||||
- `POST /admin/subscription/revoke`
|
||||
- `GET /admin/webhooks`
|
||||
- `GET /admin/webhooks/{id}`
|
||||
- `POST /admin/webhooks/{id}/replay`
|
||||
- `GET /admin/analytics`
|
||||
|
||||
## Endpoint details
|
||||
|
||||
### `GET /emojis`
|
||||
|
||||
Query params:
|
||||
- `q` (string): search query (also accepts `query`)
|
||||
- `category` (string): category name (e.g. `Smileys & Emotion`)
|
||||
- `subcategory` (string): subcategory name (will be slugified internally)
|
||||
- `page` (int, default 1)
|
||||
- `limit` (int):
|
||||
- Free tier: max 20
|
||||
- Pro tier: max 50
|
||||
|
||||
Behavior:
|
||||
- If `q` is empty, it returns all emojis (subject to pagination).
|
||||
- If `q` has multiple terms, all terms must match.
|
||||
- `category` must match the exact category label in the dataset.
|
||||
- `subcategory` is matched by slugifying both the request and dataset.
|
||||
- `plan` field in response will be `free` or `pro`.
|
||||
- `q` or `query`
|
||||
- `category`
|
||||
- `subcategory`
|
||||
- `page` (default `1`)
|
||||
- `limit` (free max `20`, pro max `50`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | jq .
|
||||
curl -s "$BASE/emojis?q=love&limit=5" | jq .
|
||||
```
|
||||
|
||||
Response (shape):
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"emoji": "😍",
|
||||
"name": "smiling face with heart-eyes",
|
||||
"slug": "smiling-face-with-heart-eyes",
|
||||
"category": "Smileys & Emotion",
|
||||
"subcategory": "face-affection",
|
||||
"supports_skin_tone": false,
|
||||
"summary": "...",
|
||||
"unified": "U+1F60D",
|
||||
"codepoints": ["1F60D"],
|
||||
"shortcodes": [":smiling-face-with-heart-eyes:", "..."],
|
||||
"aliases": [],
|
||||
"keywords_en": ["love", "heart", "..."],
|
||||
"keywords_id": ["cinta", "..."],
|
||||
"related": ["🥰", "🤩", "😘"],
|
||||
"intent_tags": ["love", "affection"]
|
||||
}
|
||||
],
|
||||
"total": 2131,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"plan": "free"
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/emoji/{slug}` or `/emoji?slug=...`
|
||||
Fetch detail for a specific emoji.
|
||||
### `GET /emoji/{slug}` or `GET /emoji?slug=...`
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/v1/emoji/grinning-face" | jq .
|
||||
curl -s "$BASE/emoji/grinning-face" | jq .
|
||||
```
|
||||
|
||||
Errors:
|
||||
- `400` if slug is missing (`error: missing_slug`)
|
||||
- `404` if slug is not found (`error: not_found`)
|
||||
|
||||
### GET `/categories`
|
||||
Returns a map of category → list of subcategories.
|
||||
- `400` `missing_slug`
|
||||
- `404` `not_found`
|
||||
|
||||
### `GET /categories`
|
||||
|
||||
Returns `category -> subcategories[]` mapping.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/v1/categories" | jq .
|
||||
curl -s "$BASE/categories" | jq .
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"Smileys & Emotion": ["face-smiling", "face-affection", "..."],
|
||||
"People & Body": ["hand-fingers-open", "..."]
|
||||
}
|
||||
```
|
||||
### `POST /license/verify`
|
||||
|
||||
### POST `/license/verify`
|
||||
Validate a license key.
|
||||
|
||||
Headers:
|
||||
- `Authorization: Bearer YOUR_LICENSE_KEY` (or `X-License-Key`)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -s -X POST "http://127.0.0.1:8000/v1/license/verify" \
|
||||
-H "Authorization: Bearer YOUR_LICENSE_KEY" | jq .
|
||||
KEY=YOUR_LICENSE_KEY
|
||||
curl -s -X POST "$BASE/license/verify" \
|
||||
-H "Authorization: Bearer $KEY" | jq .
|
||||
```
|
||||
|
||||
Success response:
|
||||
Success shape (example):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"tier": "pro",
|
||||
"source": "gumroad|mayar|sandbox|...",
|
||||
"plan": "pro",
|
||||
"product_id": "…",
|
||||
"product_id": "...",
|
||||
"expires_at": null,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
- `400` `missing_key`
|
||||
- `401` `invalid_license`
|
||||
### `POST /license/activate`
|
||||
|
||||
### POST `/license/activate`
|
||||
Activate a device or site session.
|
||||
|
||||
Body (JSON):
|
||||
```json
|
||||
{
|
||||
"email": "you@example.com",
|
||||
"product": "extension|site",
|
||||
"device_id": "device-123"
|
||||
}
|
||||
```bash
|
||||
KEY=YOUR_LICENSE_KEY
|
||||
curl -s -X POST "$BASE/license/activate" \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"you@example.com","product":"extension","device_id":"local-dev"}' | jq .
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `product=site` does not require `device_id`.
|
||||
- Other products require `device_id`.
|
||||
|
||||
### POST `/license/deactivate`
|
||||
Deactivate a device.
|
||||
- `product=site` can omit `device_id`.
|
||||
|
||||
Body (JSON):
|
||||
```json
|
||||
{
|
||||
"product": "extension|site",
|
||||
"device_id": "device-123"
|
||||
}
|
||||
```
|
||||
### `POST /license/deactivate`
|
||||
|
||||
### GET `/health`
|
||||
Basic health check.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "ok": true, "time": "...", "app": "Dewemoji" }
|
||||
```
|
||||
|
||||
### GET `/metrics-lite`
|
||||
Lightweight metrics (if enabled).
|
||||
|
||||
### GET `/metrics`
|
||||
Full metrics (requires token or allowed IP).
|
||||
|
||||
## Extension verification (public, no login)
|
||||
|
||||
These endpoints allow **verified extension installs** to access public search without login.
|
||||
|
||||
Fallback (temporary):
|
||||
- If Verified Access token is not available, the server can accept `X-Extension-Id`
|
||||
as a **soft allowlist** signal. This is **spoofable** and should not be treated as
|
||||
a strong security boundary.
|
||||
|
||||
### POST `/extension/verify`
|
||||
Verifies `X-Extension-Token` by calling Google Instance ID API.
|
||||
|
||||
Headers:
|
||||
- `X-Extension-Token: <token>`
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "ok": true, "verified": true }
|
||||
```
|
||||
|
||||
### GET `/extension/search`
|
||||
Public search for verified extension installs only.
|
||||
|
||||
Headers:
|
||||
- `X-Extension-Token: <token>`
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/v1/extension/search?q=snail" \
|
||||
-H "X-Extension-Token: $EXT_TOKEN" | jq .
|
||||
KEY=YOUR_LICENSE_KEY
|
||||
curl -s -X POST "$BASE/license/deactivate" \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"product":"extension","device_id":"local-dev"}' | jq .
|
||||
```
|
||||
|
||||
Errors:
|
||||
- `403 extension_unverified`
|
||||
### `GET /health`
|
||||
|
||||
## Caching
|
||||
|
||||
The API uses `ETag` and returns `304 Not Modified` if the client sends:
|
||||
```
|
||||
If-None-Match: "etag-value"
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
ETAG=$(curl -i "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | awk -F': ' '/^ETag:/ {print $2}' | tr -d '\r')
|
||||
curl -i -H "If-None-Match: $ETAG" "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | head -n 1
|
||||
curl -s "$BASE/health" | jq .
|
||||
```
|
||||
|
||||
## Public access guard (whitelist + soft throttle)
|
||||
## Public access guard
|
||||
|
||||
Public endpoints (`/v1/emojis`, `/v1/categories`, `/v1/emoji`) are protected by a **whitelist + hourly throttle**.
|
||||
Public endpoints use whitelist + throttle behavior:
|
||||
|
||||
Behavior:
|
||||
- If the request is **whitelisted**, it passes without throttling.
|
||||
- If not whitelisted:
|
||||
- If `DEWEMOJI_PUBLIC_ENFORCE=true` → `403 public_access_denied`
|
||||
- Else → **soft throttle** with `DEWEMOJI_PUBLIC_HOURLY_LIMIT` (HTTP `429 public_rate_limited`)
|
||||
- If request is whitelisted: pass.
|
||||
- If not whitelisted and `DEWEMOJI_PUBLIC_ENFORCE=true`: `403 public_access_denied`.
|
||||
- Otherwise: soft throttle with `DEWEMOJI_PUBLIC_HOURLY_LIMIT` and `429 public_rate_limited`.
|
||||
|
||||
Whitelist rules:
|
||||
- `Origin` is in `DEWEMOJI_PUBLIC_ORIGINS`
|
||||
- Or `X-Dewemoji-Frontend` / `User-Agent` contains a configured extension ID
|
||||
Key env:
|
||||
|
||||
Key env vars:
|
||||
```
|
||||
```env
|
||||
DEWEMOJI_PUBLIC_ENFORCE=true|false
|
||||
DEWEMOJI_PUBLIC_ORIGINS=https://dewemoji.com,https://www.dewemoji.com
|
||||
DEWEMOJI_PUBLIC_HOURLY_LIMIT=5000
|
||||
DEWEMOJI_EXTENSION_IDS=chrome-extension://...
|
||||
```
|
||||
|
||||
Rate limit response example:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "public_rate_limited",
|
||||
"usage": {
|
||||
"used": 3,
|
||||
"limit": 3,
|
||||
"remaining": 0,
|
||||
"window": "hourly",
|
||||
"window_ends_at": "2026-02-08T14:00:00+00:00",
|
||||
"window_ends_at_unix": 1770559200
|
||||
}
|
||||
}
|
||||
```
|
||||
Security note:
|
||||
|
||||
## Admin endpoints (token required)
|
||||
- `Origin`/`Referer` are not strong security controls. Use edge/WAF/API keys for stronger protection.
|
||||
|
||||
All admin endpoints require:
|
||||
```
|
||||
X-Admin-Token: <DEWEMOJI_ADMIN_TOKEN>
|
||||
```
|
||||
## Caching behavior
|
||||
|
||||
### Settings (feature flags / public access)
|
||||
- `GET /v1/admin/settings`
|
||||
- `POST /v1/admin/settings`
|
||||
ETag is supported. `If-None-Match` can return `304 Not Modified`.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl -s -X POST "http://127.0.0.1:8000/v1/admin/settings" \
|
||||
-H "X-Admin-Token: $DEWEMOJI_ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"settings": {
|
||||
"maintenance_enabled": false,
|
||||
"public_enforce": true,
|
||||
"public_hourly_limit": 5000,
|
||||
"public_origins": ["https://dewemoji.com","https://www.dewemoji.com"],
|
||||
"public_extension_ids": ["chrome-extension://yourid"]
|
||||
}
|
||||
}' | jq .
|
||||
ETAG=$(curl -i "$BASE/emojis?q=love&limit=5" | awk -F': ' '/^ETag:/ {print $2}' | tr -d '\r')
|
||||
curl -i -H "If-None-Match: $ETAG" "$BASE/emojis?q=love&limit=5" | head -n 1
|
||||
```
|
||||
|
||||
### Subscriptions (admin grant/revoke)
|
||||
- `GET /v1/admin/subscriptions`
|
||||
- `POST /v1/admin/subscription/grant`
|
||||
- `POST /v1/admin/subscription/revoke`
|
||||
## Smoke test checklist
|
||||
|
||||
### Health
|
||||
|
||||
Grant example:
|
||||
```bash
|
||||
curl -s -X POST "http://127.0.0.1:8000/v1/admin/subscription/grant" \
|
||||
-H "X-Admin-Token: $DEWEMOJI_ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","plan":"personal","status":"active","provider":"admin"}' | jq .
|
||||
curl -s "$BASE/health" | jq .
|
||||
```
|
||||
|
||||
### Webhooks (PayPal)
|
||||
- `POST /v1/paypal/webhook` (logs + processes)
|
||||
- `GET /v1/admin/webhooks`
|
||||
- `GET /v1/admin/webhooks/{id}`
|
||||
- `POST /v1/admin/webhooks/{id}/replay`
|
||||
Expected: `{ "ok": true, ... }`
|
||||
|
||||
Deduping:
|
||||
- PayPal `id` is stored as `event_id`. Duplicate events return `{ "ok": true, "duplicate": true }`.
|
||||
### Categories
|
||||
|
||||
Note:
|
||||
- PayPal **signature verification is not implemented yet** (TODO before production).
|
||||
|
||||
### Analytics
|
||||
- `GET /v1/admin/analytics` (counts for users/keywords/subscriptions/webhooks)
|
||||
|
||||
## Security note: Origin/Referer are spoofable
|
||||
|
||||
The `Origin` / `Referer` headers are **not** a strong security boundary. They are used to reduce casual abuse only.
|
||||
|
||||
Options to harden public access:
|
||||
- **Require API keys** for unlimited access (personal users).
|
||||
- **Rate limit at the edge** (Cloudflare / Nginx).
|
||||
- **Signed requests** for extension (HMAC or short‑lived tokens).
|
||||
- **Shared secret header** for internal services.
|
||||
- **WAF rules** for `/v1/*` endpoints.
|
||||
|
||||
## Error payloads (common)
|
||||
|
||||
```json
|
||||
{ "ok": false, "error": "not_found" }
|
||||
```bash
|
||||
curl -s "$BASE/categories" | jq 'keys | length'
|
||||
```
|
||||
|
||||
Other possible errors:
|
||||
- `missing_slug`
|
||||
- `missing_key`
|
||||
- `invalid_license`
|
||||
- `daily_limit_reached`
|
||||
- `data_load_failed`
|
||||
Expected: count > 0.
|
||||
|
||||
## Notes
|
||||
### Search
|
||||
|
||||
- Current dataset contains **EN + ID** keywords.
|
||||
- API reads from `app/data/emojis.json` (cache-first strategy).
|
||||
- Free tier limit is enforced on **page=1** requests.
|
||||
```bash
|
||||
curl -s "$BASE/emojis?q=love&limit=5" | jq '.items | length'
|
||||
```
|
||||
|
||||
Expected: count > 0.
|
||||
|
||||
### Detail
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/emoji/grinning-face" | jq '.slug,.name'
|
||||
```
|
||||
|
||||
### Free-tier rate-limit headers
|
||||
|
||||
```bash
|
||||
curl -i "$BASE/emojis?limit=1&page=1" | grep -E "HTTP|X-RateLimit"
|
||||
```
|
||||
|
||||
### Verify / activate / deactivate
|
||||
|
||||
Use the commands in endpoint details above.
|
||||
|
||||
### Error response check
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/emoji/this-does-not-exist" | jq .
|
||||
```
|
||||
|
||||
Expected `error: not_found`.
|
||||
|
||||
## Legacy compatibility notes
|
||||
|
||||
Kept for extension/backward compatibility:
|
||||
|
||||
- accepts both `q` and `query`
|
||||
- supports `Authorization` and `X-License-Key`
|
||||
- includes `X-Dewemoji-Tier`
|
||||
|
||||
Legacy contract references (`/api/*` in old stack) were consolidated here. No separate legacy API spec file is needed now.
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Dewemoji API Test Document
|
||||
|
||||
This is a quick, repeatable checklist for validating the API locally or on staging.
|
||||
|
||||
## Base URLs
|
||||
|
||||
- Local: `http://127.0.0.1:8000/v1`
|
||||
- Staging: `https://dewemoji.backoffice.biz.id/v1`
|
||||
|
||||
Set once in your shell for convenience:
|
||||
|
||||
```bash
|
||||
BASE=http://127.0.0.1:8000/v1
|
||||
# BASE=https://dewemoji.backoffice.biz.id/v1
|
||||
```
|
||||
|
||||
## Health
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/health" | jq .
|
||||
```
|
||||
|
||||
Expected: `{ "ok": true, ... }`
|
||||
|
||||
## Categories
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/categories" | jq 'keys | length'
|
||||
```
|
||||
|
||||
Expected: number > 0.
|
||||
|
||||
## Emoji Search
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/emojis?q=love&limit=5" | jq '.items | length'
|
||||
```
|
||||
|
||||
Expected: number > 0.
|
||||
|
||||
## Emoji Detail
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/emoji/grinning-face" | jq '.slug,.name'
|
||||
```
|
||||
|
||||
Expected: `"grinning-face"` and `"grinning face"`.
|
||||
|
||||
## Rate-limit (Free tier)
|
||||
|
||||
```bash
|
||||
curl -i "$BASE/emojis?limit=1&page=1" | grep -E "HTTP|X-RateLimit"
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `X-RateLimit-Limit`
|
||||
- `X-RateLimit-Remaining`
|
||||
- `X-RateLimit-Reset`
|
||||
|
||||
Note:
|
||||
- Local dev disables rate limiting by default, so headers may not appear on `http://127.0.0.1`.
|
||||
|
||||
## Pro key test (if you have a key)
|
||||
|
||||
```bash
|
||||
KEY=YOUR_LICENSE_KEY
|
||||
curl -s -H "Authorization: Bearer $KEY" "$BASE/emojis?q=love&limit=50" | jq '.limit,.plan'
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `limit` up to 50
|
||||
- `plan` = `free` or `pro` (depending on key validation).
|
||||
|
||||
## License verify / activate / deactivate
|
||||
|
||||
```bash
|
||||
KEY=YOUR_LICENSE_KEY
|
||||
|
||||
curl -s -X POST "$BASE/license/verify" \
|
||||
-H "Authorization: Bearer $KEY" | jq .
|
||||
|
||||
curl -s -X POST "$BASE/license/activate" \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"you@example.com","product":"extension","device_id":"local-dev"}' | jq .
|
||||
|
||||
curl -s -X POST "$BASE/license/deactivate" \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"product":"extension","device_id":"local-dev"}' | jq .
|
||||
```
|
||||
|
||||
Expected:
|
||||
- verify → `{ ok: true }` if key is valid.
|
||||
- activate → `{ ok: true }` and `device_id` echoed.
|
||||
- deactivate → `{ ok: true }`.
|
||||
|
||||
## Caching (ETag)
|
||||
|
||||
```bash
|
||||
ETAG=$(curl -i "$BASE/emojis?q=love&limit=5" | awk -F': ' '/^ETag:/ {print $2}' | tr -d '\r')
|
||||
curl -i -H "If-None-Match: $ETAG" "$BASE/emojis?q=love&limit=5" | head -n 1
|
||||
```
|
||||
|
||||
Expected: `HTTP/1.1 304 Not Modified`
|
||||
|
||||
## Error responses
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/emoji/this-does-not-exist" | jq .
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `error` = `not_found`
|
||||
@@ -112,3 +112,13 @@ DEWEMOJI_EXTENSION_VERIFY_ENABLED=true
|
||||
DEWEMOJI_GOOGLE_PROJECT_ID=
|
||||
DEWEMOJI_GOOGLE_SERVER_KEY=
|
||||
DEWEMOJI_EXTENSION_IDS=
|
||||
|
||||
DEWEMOJI_APK_RELEASE_ENABLED=false
|
||||
DEWEMOJI_APK_PUBLIC_BASE_URL=https://dewemoji.com/downloads
|
||||
DEWEMOJI_R2_PUBLIC_BASE_URL=
|
||||
DEWEMOJI_R2_APK_LATEST_KEY=apk/dewemoji-latest.apk
|
||||
DEWEMOJI_R2_APK_VERSION_KEY=apk/version.json
|
||||
DEWEMOJI_APK_APP_ID=com.dewemoji.app
|
||||
DEWEMOJI_APK_CHANNEL=stable
|
||||
DEWEMOJI_APK_MIN_SUPPORTED_VERSION_CODE=1
|
||||
DEWEMOJI_APK_ASSETLINKS_SHA256=
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -110,9 +110,9 @@ class EmojiApiController extends Controller
|
||||
$page = max((int) $request->query('page', 1), 1);
|
||||
|
||||
$defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1);
|
||||
$maxLimit = $tier === self::TIER_PRO
|
||||
? max((int) config('dewemoji.pagination.pro_max_limit', 50), 1)
|
||||
: max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
|
||||
// Search result pagination is now feature-parity for all users.
|
||||
// Keyword/glossary limits are enforced elsewhere (user_keywords quota logic).
|
||||
$maxLimit = max((int) config('dewemoji.pagination.max_limit', 50), 1);
|
||||
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
|
||||
|
||||
$filtered = $this->filterItems($items, $q, $category, $subSlug);
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Subscription;
|
||||
use App\Models\UserKeyword;
|
||||
use App\Services\System\SettingsService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -264,7 +265,77 @@ class SiteController extends Controller
|
||||
|
||||
public function download(): View
|
||||
{
|
||||
return view('site.download');
|
||||
$downloadBaseUrl = rtrim((string) config('dewemoji.apk_release.public_base_url', ''), '/');
|
||||
$androidEnabled = (bool) config('dewemoji.apk_release.enabled', false) && $downloadBaseUrl !== '';
|
||||
|
||||
return view('site.download', [
|
||||
'androidEnabled' => $androidEnabled,
|
||||
'androidVersionJsonUrl' => $androidEnabled ? $downloadBaseUrl.'/version.json' : '',
|
||||
'androidLatestApkUrl' => $androidEnabled ? $downloadBaseUrl.'/dewemoji-latest.apk' : '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadVersionJson(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$target = $this->apkReleaseTargetUrl('version_json');
|
||||
if ($target === '') {
|
||||
return response()->json(['ok' => false, 'error' => 'apk_release_not_configured'], 404);
|
||||
}
|
||||
|
||||
return redirect()->away($target, 302, [
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadLatestApk(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$target = $this->apkReleaseTargetUrl('latest_apk');
|
||||
if ($target === '') {
|
||||
return response()->json(['ok' => false, 'error' => 'apk_release_not_configured'], 404);
|
||||
}
|
||||
|
||||
return redirect()->away($target, 302, [
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
public function assetLinks(): JsonResponse
|
||||
{
|
||||
$appId = trim((string) config('dewemoji.apk_release.app_id', ''));
|
||||
$rawFingerprints = (array) config('dewemoji.apk_release.assetlinks.fingerprints', []);
|
||||
$fingerprints = [];
|
||||
foreach ($rawFingerprints as $fingerprint) {
|
||||
$normalized = $this->normalizeApkCertFingerprint((string) $fingerprint);
|
||||
if ($normalized !== '') {
|
||||
$fingerprints[] = $normalized;
|
||||
}
|
||||
}
|
||||
$fingerprints = array_values(array_unique($fingerprints));
|
||||
|
||||
if ($appId === '' || $fingerprints === []) {
|
||||
return response()->json([], 200, [
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
[
|
||||
'relation' => [
|
||||
'delegate_permission/common.handle_all_urls',
|
||||
],
|
||||
'target' => [
|
||||
'namespace' => 'android_app',
|
||||
'package_name' => $appId,
|
||||
'sha256_cert_fingerprints' => $fingerprints,
|
||||
],
|
||||
],
|
||||
], 200, [
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
public function privacy(): View
|
||||
@@ -465,6 +536,39 @@ class SiteController extends Controller
|
||||
return (string) config('dewemoji.data_path');
|
||||
}
|
||||
|
||||
private function apkReleaseTargetUrl(string $key): string
|
||||
{
|
||||
if (!(bool) config('dewemoji.apk_release.enabled', false)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$base = trim((string) config('dewemoji.apk_release.r2_public_base_url', ''));
|
||||
$objectKey = trim((string) config("dewemoji.apk_release.r2_keys.{$key}", ''));
|
||||
if ($base === '' || $objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return rtrim($base, '/').'/'.ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
private function normalizeApkCertFingerprint(string $value): string
|
||||
{
|
||||
$clean = strtoupper(trim($value));
|
||||
if ($clean === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('/^[0-9A-F]{64}$/', $clean) === 1) {
|
||||
return implode(':', str_split($clean, 2));
|
||||
}
|
||||
|
||||
if (preg_match('/^[0-9A-F]{2}(?::[0-9A-F]{2}){31}$/', $clean) === 1) {
|
||||
return $clean;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $emoji
|
||||
*/
|
||||
|
||||
@@ -123,4 +123,20 @@ return [
|
||||
'token' => (string) env('DEWEMOJI_METRICS_TOKEN', ''),
|
||||
'allow_ips' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_METRICS_ALLOW_IPS', '127.0.0.1,::1'))))),
|
||||
],
|
||||
|
||||
'apk_release' => [
|
||||
'enabled' => filter_var(env('DEWEMOJI_APK_RELEASE_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'app_id' => (string) env('DEWEMOJI_APK_APP_ID', 'com.dewemoji.app'),
|
||||
'channel' => (string) env('DEWEMOJI_APK_CHANNEL', 'stable'),
|
||||
'min_supported_version_code' => (int) env('DEWEMOJI_APK_MIN_SUPPORTED_VERSION_CODE', 1),
|
||||
'public_base_url' => (string) env('DEWEMOJI_APK_PUBLIC_BASE_URL', 'https://dewemoji.com/downloads'),
|
||||
'r2_public_base_url' => (string) env('DEWEMOJI_R2_PUBLIC_BASE_URL', ''),
|
||||
'r2_keys' => [
|
||||
'latest_apk' => (string) env('DEWEMOJI_R2_APK_LATEST_KEY', 'apk/dewemoji-latest.apk'),
|
||||
'version_json' => (string) env('DEWEMOJI_R2_APK_VERSION_KEY', 'apk/version.json'),
|
||||
],
|
||||
'assetlinks' => [
|
||||
'fingerprints' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_APK_ASSETLINKS_SHA256', ''))))),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
# Dewemoji Billing Integration Plan (QRIS + PayPal)
|
||||
|
||||
This document outlines a proper, production-grade billing flow for Dewemoji using **QRIS (Pakasir)** and **PayPal Subscriptions**, including webhooks, retries, and license activation.
|
||||
|
||||
---
|
||||
|
||||
## 1) Goals
|
||||
|
||||
- Replace primitive payment links with real provider integrations.
|
||||
- Support **subscription** billing (monthly/annual) and **one-time lifetime**.
|
||||
- Activate or revoke licenses based on webhook-confirmed payments.
|
||||
- Log all webhook events and payment activity for audit.
|
||||
|
||||
---
|
||||
|
||||
## 2) Data Model
|
||||
|
||||
### `orders` (new)
|
||||
|
||||
Acts as the primary record of what the user is buying. Payments link back to orders.
|
||||
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `plan_code`
|
||||
- `type` (`one_time`, `subscription`)
|
||||
- `currency` (`IDR`, `USD`)
|
||||
- `amount`
|
||||
- `status` (`pending`, `paid`, `failed`, `expired`, `refunded`)
|
||||
- `provider` (`qris`, `paypal`)
|
||||
- `provider_ref`
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### `payments` (new)
|
||||
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `order_id`
|
||||
- `provider` (`qris`, `paypal`)
|
||||
- `type` (`one_time`, `subscription`)
|
||||
- `plan_code` (`personal_monthly`, `personal_annual`, `personal_lifetime`)
|
||||
- `currency` (`IDR`, `USD`)
|
||||
- `amount`
|
||||
- `status` (`pending`, `paid`, `failed`, `expired`, `refunded`)
|
||||
- `provider_ref` (invoice_id / order_id / subscription_id)
|
||||
- `raw_payload` (json)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### `subscriptions` (existing)
|
||||
Extend with:
|
||||
- `provider`
|
||||
- `provider_ref`
|
||||
- `status` (`active`, `pending`, `canceled`, `expired`)
|
||||
- `started_at`, `expires_at`, `canceled_at`
|
||||
- `next_renewal_at` (optional)
|
||||
|
||||
### `webhook_events` (existing)
|
||||
Continue to log inbound payloads and processing status:
|
||||
- `provider`, `event_type`, `status`, `payload`, `received_at`, `processed_at`, `error_message`
|
||||
|
||||
---
|
||||
|
||||
## 3) Payment Flow (User Journey)
|
||||
|
||||
### Pricing Page (Frontend)
|
||||
|
||||
Each plan shows:
|
||||
- **Primary currency** (based on geo + user toggle)
|
||||
- **Two payment buttons** (real provider flow):
|
||||
- **QRIS (IDR)** → subscription or one-time
|
||||
- **PayPal (USD)** → subscription or one-time
|
||||
|
||||
### Backend Endpoints
|
||||
|
||||
#### QRIS (Pakasir)
|
||||
- `POST /billing/qris/create`
|
||||
- Creates invoice via Pakasir API
|
||||
- Stores `payments` with `pending`
|
||||
- Returns QR payment URL or QR code data
|
||||
- `GET /billing/qris/return` (optional)
|
||||
- Shows “pending / processing” state
|
||||
|
||||
#### PayPal Subscriptions
|
||||
- `POST /billing/paypal/create`
|
||||
- Creates PayPal subscription
|
||||
- Stores `payments` with `pending`
|
||||
- Returns approval URL
|
||||
- `GET /billing/paypal/return`
|
||||
- Shows “pending / processing” state
|
||||
|
||||
---
|
||||
|
||||
## 4) Webhook Processing (Critical)
|
||||
|
||||
Webhook endpoint:
|
||||
```
|
||||
POST /webhooks/{provider}
|
||||
```
|
||||
|
||||
Store inbound payloads in `webhook_events`, then process async (queue).
|
||||
|
||||
### PayPal Events
|
||||
- `BILLING.SUBSCRIPTION.ACTIVATED` → mark subscription active, set `users.tier = personal`
|
||||
- `BILLING.SUBSCRIPTION.CANCELLED` → mark subscription canceled
|
||||
- `PAYMENT.SALE.COMPLETED` → mark payment paid
|
||||
- `PAYMENT.SALE.DENIED` → mark payment failed
|
||||
|
||||
### Pakasir / QRIS Events
|
||||
- `payment.paid` → mark payment paid, grant access
|
||||
- `payment.expired` → mark payment failed/expired
|
||||
|
||||
---
|
||||
|
||||
## 5) License Activation Rules
|
||||
|
||||
When a payment or subscription is confirmed:
|
||||
- Create or update a `subscriptions` record
|
||||
- Set `users.tier = personal`
|
||||
- Store provider refs (`provider_ref`)
|
||||
- Log admin audit record
|
||||
|
||||
When revoked/expired:
|
||||
- Update `subscriptions.status`
|
||||
- Downgrade user if no active subscription remains
|
||||
|
||||
### Renewal Logic (QRIS manual renew)
|
||||
|
||||
- **If still active:** extend from current `expires_at`
|
||||
- `expires_at = expires_at + duration`
|
||||
- **If expired:** start from now
|
||||
- `expires_at = now + duration`
|
||||
|
||||
---
|
||||
|
||||
## 6) Admin Dashboard Enhancements
|
||||
|
||||
Add or extend:
|
||||
- **Payments list** (new screen)
|
||||
- filter by provider/status/currency
|
||||
- show raw provider ref
|
||||
- **Subscriptions list** (already exists)
|
||||
- show provider + status
|
||||
- **Webhook events** (already exists)
|
||||
- replay capability
|
||||
|
||||
---
|
||||
|
||||
## 7) Security & Reliability
|
||||
|
||||
- Validate webhook signatures (PayPal + Pakasir)
|
||||
- Reject duplicate events (idempotency)
|
||||
- Use queues for webhook processing
|
||||
- Log all webhook failures
|
||||
|
||||
---
|
||||
|
||||
## 8) Required Inputs (From Owner)
|
||||
|
||||
Before implementation:
|
||||
|
||||
1. **Pakasir API docs** (create invoice, webhook payload format)
|
||||
2. **PayPal API credentials** (client_id, secret, webhook signing key)
|
||||
3. Confirm **plans & pricing**:
|
||||
- Monthly
|
||||
- Annual
|
||||
- Lifetime
|
||||
|
||||
---
|
||||
|
||||
## 9) Implementation Phases
|
||||
|
||||
**Phase 1 — Schema + Core Models**
|
||||
- Add `orders` table
|
||||
- Add `payments` table (link to orders)
|
||||
- Extend `subscriptions`
|
||||
- Update webhook model if needed
|
||||
|
||||
**Phase 2 — Provider APIs**
|
||||
- Pakasir invoice create
|
||||
- PayPal subscription create
|
||||
|
||||
**Phase 3 — Webhooks**
|
||||
- Save raw events
|
||||
- Process via queue + idempotency
|
||||
|
||||
**Phase 4 — UI**
|
||||
- Pricing page buttons → real flows
|
||||
- Admin payment + subscription tools
|
||||
|
||||
---
|
||||
|
||||
## 10) Notes
|
||||
|
||||
- This plan assumes **proper subscription lifecycle** with webhooks.
|
||||
- PayPal.me / static links are **not sufficient** for subscriptions.
|
||||
- All access control must be tied to **confirmed payment status**.
|
||||
@@ -1,143 +0,0 @@
|
||||
# Dewemoji User Dashboard Implementation Plan
|
||||
|
||||
**Source:** `dewemoji-ux-flow-brief.md`
|
||||
**Goal:** Build the Personal user dashboard + inline personalization flows aligned with the UX brief.
|
||||
|
||||
---
|
||||
|
||||
## 1) Scope & Principles
|
||||
|
||||
- **Primary flow**: Add keywords directly on emoji detail pages (fast, contextual).
|
||||
- **Secondary flow**: Manage keywords in the dashboard (bulk + power tools).
|
||||
- **Zero friction** for Visitors/Free users; gentle upgrade prompts.
|
||||
- **Shared layout** with admin (same shell, role-based sidebar).
|
||||
|
||||
---
|
||||
|
||||
## 2) User States & Routing
|
||||
|
||||
### Visitor (non-logged)
|
||||
- Public search + emoji detail only.
|
||||
- CTA: “Sign up free” / “Upgrade to Personal”.
|
||||
|
||||
### Free user (logged, no subscription)
|
||||
- See public content, “Your keywords” section locked.
|
||||
- Upgrade nudges on detail + empty states in dashboard.
|
||||
|
||||
### Personal user (paid)
|
||||
- Full access: quick add on detail + dashboard CRUD.
|
||||
|
||||
---
|
||||
|
||||
## 3) UI Screens (User Dashboard)
|
||||
|
||||
### 3.1 Dashboard shell (shared)
|
||||
- Same layout as admin (sidebar, top bar).
|
||||
- Role-based sidebar menu:
|
||||
- Overview
|
||||
- My Keywords
|
||||
- API Keys
|
||||
- Billing
|
||||
- Preferences
|
||||
- Support / Logout
|
||||
|
||||
### 3.2 Overview (user)
|
||||
- Show:
|
||||
- Total keywords
|
||||
- Recent keyword additions (last 7 days)
|
||||
- Synced devices (optional later)
|
||||
- Small quick link to “My Keywords”.
|
||||
|
||||
### 3.3 My Keywords (primary management)
|
||||
- Table: Emoji | Your keywords | Language | Actions
|
||||
- Toolbar:
|
||||
- + Add Keyword
|
||||
- Import JSON
|
||||
- Export JSON
|
||||
- Search/filter
|
||||
- Modal: emoji picker → add keywords + language.
|
||||
|
||||
### 3.4 API Keys
|
||||
- List user API keys
|
||||
- Create/revoke key
|
||||
|
||||
### 3.5 Billing
|
||||
- Current plan + renewal / expiry
|
||||
- Payment method (future)
|
||||
- Upgrade CTA if free
|
||||
|
||||
### 3.6 Preferences
|
||||
- Theme
|
||||
- Tone lock / preferred skin tone (future)
|
||||
- Locale
|
||||
|
||||
---
|
||||
|
||||
## 4) Site Page Enhancements (Non-dashboard)
|
||||
|
||||
### 4.1 Emoji Detail Page (critical)
|
||||
- Show public keywords for everyone.
|
||||
- If Personal: show “Your Keywords” list + quick add modal.
|
||||
- If Free: show locked section + upgrade CTA.
|
||||
- If Visitor: CTA to sign up.
|
||||
|
||||
### 4.2 Search Results Page
|
||||
- Personal user: blend public + user keywords.
|
||||
- Show “Your keyword” badge and “quick edit” button.
|
||||
|
||||
---
|
||||
|
||||
## 5) API Endpoints (v1)
|
||||
|
||||
### Keyword CRUD
|
||||
- `GET /v1/emoji/{slug}?include_user_keywords=true`
|
||||
- `POST /v1/keywords` (add keyword)
|
||||
- `PUT /v1/keywords/{id}` (edit)
|
||||
- `DELETE /v1/keywords/{id}`
|
||||
- `GET /v1/keywords` (list user keywords)
|
||||
|
||||
### User keyword import/export
|
||||
- `POST /v1/keywords/import`
|
||||
- `GET /v1/keywords/export`
|
||||
|
||||
### Dashboard data
|
||||
- `GET /v1/user/summary` (counts + recents)
|
||||
- `GET /v1/user/apikeys` / `POST /v1/user/apikeys`
|
||||
- `GET /v1/user/billing` (subscription status)
|
||||
|
||||
---
|
||||
|
||||
## 6) Database / Models
|
||||
|
||||
Existing:
|
||||
- `user_keywords`
|
||||
- `subscriptions`
|
||||
|
||||
Add if needed:
|
||||
- `user_keyword_imports` (optional audit)
|
||||
|
||||
---
|
||||
|
||||
## 7) Implementation Phases
|
||||
|
||||
### Phase A — Foundation
|
||||
- Add user routes + dashboard views
|
||||
- Layout reuse with role‑based sidebar
|
||||
|
||||
### Phase B — Keywords UX
|
||||
- Detail page quick add
|
||||
- Dashboard keyword CRUD + import/export
|
||||
|
||||
### Phase C — Billing & API Keys
|
||||
- Billing summary + upgrade CTA
|
||||
- API key list + create/revoke
|
||||
|
||||
---
|
||||
|
||||
## 8) Acceptance Criteria
|
||||
|
||||
- Personal user can add keywords from detail page in <5 seconds.
|
||||
- Keyword appears in search results immediately.
|
||||
- Dashboard keyword table supports filter + edit + delete.
|
||||
- Free users see upgrade prompts, not broken UI.
|
||||
|
||||
@@ -105,47 +105,47 @@
|
||||
<button id="quick-action-btn" class="rounded-full bg-white/10 text-white border border-white/10 px-5 py-2 text-sm font-semibold hover:bg-white/20 transition-colors">
|
||||
Quick action
|
||||
</button>
|
||||
<div id="quick-action-menu" class="hidden absolute right-0 mt-2 w-60 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-white/10 dark:bg-[#0b0b0f]/95 dark:backdrop-blur">
|
||||
<div id="quick-action-menu" class="hidden absolute left-0 sm:left-auto sm:right-0 mt-2 w-60 max-w-[calc(100vw-2rem)] rounded-2xl border border-slate-200 bg-white p-2 shadow-xl z-50 dark:border-white/10 dark:bg-[#0b0b0f]/95 dark:backdrop-blur">
|
||||
<div class="text-[11px] uppercase tracking-[0.3em] text-slate-500 px-3 py-2 dark:text-gray-500">Actions</div>
|
||||
@if ($isAdmin)
|
||||
<a href="{{ route('dashboard.admin.users') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.users') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="users" class="w-4 h-4"></i><span>Manage users</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.subscriptions') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.subscriptions') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="credit-card" class="w-4 h-4"></i><span>Grant subscription</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.catalog') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.catalog') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="package-search" class="w-4 h-4"></i><span>Manage catalog</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.webhooks') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.webhooks') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="webhook" class="w-4 h-4"></i><span>Review webhooks</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.audit_logs') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.audit_logs') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="list-checks" class="w-4 h-4"></i><span>Audit logs</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.settings') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.settings') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="settings" class="w-4 h-4"></i><span>Update settings</span>
|
||||
</a>
|
||||
<a href="{{ route('profile.edit') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('profile.edit') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="user-round" class="w-4 h-4"></i><span>Edit profile</span>
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('dashboard.keywords') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.keywords') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="hash" class="w-4 h-4"></i><span>My keywords</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.keywords') }}#add" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.keywords') }}#add" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="plus-circle" class="w-4 h-4"></i><span>Add keyword</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.api-keys') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.api-keys') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="key-round" class="w-4 h-4"></i><span>Manage API keys</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.billing') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.billing') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="badge-dollar-sign" class="w-4 h-4"></i><span>Billing overview</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.preferences') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.preferences') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="settings-2" class="w-4 h-4"></i><span>Preferences</span>
|
||||
</a>
|
||||
<a href="{{ route('profile.edit') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('profile.edit') }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="user-round" class="w-4 h-4"></i><span>Edit profile</span>
|
||||
</a>
|
||||
@endif
|
||||
@@ -156,15 +156,15 @@
|
||||
<button id="export-btn" class="rounded-full border border-white/10 px-5 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">
|
||||
Export
|
||||
</button>
|
||||
<div id="export-menu" class="hidden absolute right-0 mt-2 w-56 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-white/10 dark:bg-[#0b0b0f]/95 dark:backdrop-blur">
|
||||
<div id="export-menu" class="hidden absolute left-0 sm:left-auto sm:right-0 mt-2 w-56 max-w-[calc(100vw-2rem)] rounded-2xl border border-slate-200 bg-white p-2 shadow-xl z-50 dark:border-white/10 dark:bg-[#0b0b0f]/95 dark:backdrop-blur">
|
||||
<div class="text-[11px] uppercase tracking-[0.3em] text-slate-500 px-3 py-2 dark:text-gray-500">Export CSV</div>
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'users'], $exportQuery)) }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'users'], $exportQuery)) }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="users" class="w-4 h-4"></i><span>Users</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'subscriptions'], $exportQuery)) }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'subscriptions'], $exportQuery)) }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="credit-card" class="w-4 h-4"></i><span>Subscriptions</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'webhooks'], $exportQuery)) }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<a href="{{ route('dashboard.admin.export', array_merge(['type' => 'webhooks'], $exportQuery)) }}" class="flex items-center gap-3 rounded-xl px-4 py-3 min-h-11 text-base sm:text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
|
||||
<i data-lucide="webhook" class="w-4 h-4"></i><span>Webhooks</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<th class="px-4 py-3 text-left">Keyword</th>
|
||||
<th class="px-4 py-3 text-left">Language</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
<th class="hidden px-4 py-3 text-right md:table-cell">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keyword-table">
|
||||
@@ -104,7 +104,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-semibold text-white">{{ $item->keyword }}</td>
|
||||
<td class="px-4 py-3 font-semibold text-white">
|
||||
{{ $item->keyword }}
|
||||
@php
|
||||
$isActiveMobile = (bool) ($item->is_active ?? true);
|
||||
$canActivateMobile = $isActiveMobile || $isPersonal || !$limitReached;
|
||||
@endphp
|
||||
<div class="mt-3 flex flex-wrap gap-2 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="edit-btn rounded-full border border-white/10 px-3 py-1.5 text-xs text-gray-200 hover:bg-white/10"
|
||||
data-id="{{ $item->id }}"
|
||||
data-emoji="{{ $item->emoji_slug }}"
|
||||
data-keyword="{{ $item->keyword }}"
|
||||
data-lang="{{ $item->lang }}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<form method="POST" action="{{ route('dashboard.keywords.toggle_active', $item->id) }}" class="inline">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="is_active" value="{{ $isActiveMobile ? '0' : '1' }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-gray-200 hover:bg-white/10 {{ (!$canActivateMobile && !$isActiveMobile) ? 'opacity-50 cursor-not-allowed' : '' }}"
|
||||
{{ (!$canActivateMobile && !$isActiveMobile) ? 'disabled' : '' }}
|
||||
>
|
||||
{{ $isActiveMobile ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('dashboard.keywords.delete', $item->id) }}" class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-gray-200 hover:bg-white/10">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs uppercase tracking-[0.15em] text-gray-400">{{ $item->lang ?? 'und' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@php
|
||||
@@ -115,7 +152,7 @@
|
||||
{{ $isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<td class="hidden px-4 py-3 text-right md:table-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="edit-btn rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('site.layout')
|
||||
|
||||
@section('title', 'Download - Dewemoji')
|
||||
@section('meta_description', 'Download Dewemoji for Chrome and get notified when Android app is available.')
|
||||
@section('meta_description', 'Download Dewemoji for Chrome and Android.')
|
||||
|
||||
@push('jsonld')
|
||||
<script type="application/ld+json">
|
||||
@@ -78,16 +78,33 @@
|
||||
</section>
|
||||
|
||||
<section class="glass-card rounded-2xl p-6">
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Coming soon</div>
|
||||
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">
|
||||
{{ $androidEnabled ? 'Available now' : 'Coming soon' }}
|
||||
</div>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Android App</h2>
|
||||
<p class="mt-2 text-sm text-gray-300">Native app release is in progress. We will launch internal testing first, then public release.</p>
|
||||
<div class="mt-5 inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-xs text-gray-300 bg-white/5">
|
||||
<i data-lucide="smartphone" class="w-4 h-4"></i>
|
||||
Android release in preparation
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-gray-400">
|
||||
Recommended for now: use web dashboard + Chrome extension.
|
||||
</div>
|
||||
@if($androidEnabled)
|
||||
<p class="mt-2 text-sm text-gray-300">Direct APK distribution from Dewemoji download channel.</p>
|
||||
<a
|
||||
href="{{ $androidLatestApkUrl }}"
|
||||
rel="noopener"
|
||||
class="mt-5 inline-flex items-center gap-2 rounded-full bg-brand-sun text-black px-5 py-2.5 text-sm font-semibold hover:brightness-95 transition-colors"
|
||||
>
|
||||
<i data-lucide="smartphone" class="w-4 h-4"></i>
|
||||
Download APK
|
||||
</a>
|
||||
<div class="mt-4 text-xs text-gray-400">
|
||||
Update metadata: <a href="{{ $androidVersionJsonUrl }}" class="underline hover:text-gray-200">{{ $androidVersionJsonUrl }}</a>
|
||||
</div>
|
||||
@else
|
||||
<p class="mt-2 text-sm text-gray-300">Native app release is in progress. We will launch internal testing first, then public release.</p>
|
||||
<div class="mt-5 inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-xs text-gray-300 bg-white/5">
|
||||
<i data-lucide="smartphone" class="w-4 h-4"></i>
|
||||
Android release in preparation
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-gray-400">
|
||||
Recommended for now: use web dashboard + Chrome extension.
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<section class="glass-card rounded-2xl p-6 lg:col-span-2">
|
||||
@@ -100,12 +117,14 @@
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-emerald-100">Available</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<div class="flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-amber-300">
|
||||
<div class="rounded-xl {{ $androidEnabled ? 'border border-emerald-500/30 bg-emerald-500/10' : 'border border-amber-500/30 bg-amber-500/10' }} p-4">
|
||||
<div class="flex items-center gap-2 text-xs uppercase tracking-[0.2em] {{ $androidEnabled ? 'text-emerald-300' : 'text-amber-300' }}">
|
||||
<i data-lucide="bot" class="w-4 h-4"></i>
|
||||
<span>Android</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-amber-100">In progress</div>
|
||||
<div class="mt-1 text-sm {{ $androidEnabled ? 'text-emerald-100' : 'text-amber-100' }}">
|
||||
{{ $androidEnabled ? 'Available' : 'In progress' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-sky-500/30 bg-sky-500/10 p-4">
|
||||
<div class="flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-sky-300">
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
<div class="sticky top-0 z-40 -mx-4 px-4 py-3 mb-6 bg-[var(--app-bg)]/90 backdrop-blur border-b border-white/10 sm:static sm:mx-0 sm:px-0 sm:py-0 sm:mb-8 sm:border-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-gray-500 font-mono">
|
||||
<button type="button" onclick="window.history.length > 1 ? window.history.back() : (window.location.href='{{ route('home') }}')" class="inline-flex sm:hidden w-8 h-8 rounded-full bg-white/5 border border-white/10 items-center justify-center text-gray-300 hover:text-white">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<a href="{{ route('home') }}" class="hover:text-white transition-colors">Home</a>
|
||||
<i data-lucide="chevron-right" class="w-3 h-3"></i>
|
||||
<span class="hidden sm:inline-flex hover:text-white">{{ $category }}</span>
|
||||
@@ -108,8 +111,12 @@
|
||||
<div class="glass-card rounded-[32px] aspect-square flex items-center justify-center relative overflow-hidden group">
|
||||
<div class="absolute w-64 h-64 bg-yellow-500/20 rounded-full blur-3xl group-hover:bg-yellow-500/30 transition-colors duration-500"></div>
|
||||
<div id="emoji-hero-symbol" class="text-[140px] md:text-[180px] leading-none select-none relative z-10 animate-float drop-shadow-2xl">{{ $symbol }}</div>
|
||||
<div class="absolute bottom-6 flex gap-3 opacity-0 group-hover:opacity-100 transition-all transform translate-y-2 group-hover:translate-y-0">
|
||||
<button onclick="copyCurrentEmoji()" class="bg-black/50 backdrop-blur text-white force-white p-3 rounded-full hover:bg-brand-sun hover:text-black transition-colors border border-white/10" title="Copy">
|
||||
<div class="absolute bottom-6 flex gap-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all transform translate-y-0 sm:translate-y-2 sm:group-hover:translate-y-0">
|
||||
<button id="favorite-toggle-btn" type="button" class="w-12 h-12 bg-black/50 backdrop-blur text-white force-white rounded-full hover:bg-yellow-400/20 transition-colors border border-white/10 inline-flex items-center justify-center shrink-0" title="Add Favorite" aria-label="Add Favorite">
|
||||
<span id="favorite-toggle-icon" class="text-yellow-300 text-lg leading-none">☆</span>
|
||||
<span id="favorite-toggle-label" class="sr-only">Add Favorite</span>
|
||||
</button>
|
||||
<button onclick="copyCurrentEmoji()" class="w-12 h-12 bg-black/50 backdrop-blur text-white force-white rounded-full hover:bg-brand-sun hover:text-black transition-colors border border-white/10 inline-flex items-center justify-center shrink-0" title="Copy">
|
||||
<i data-lucide="copy" class="w-5 h-5 force-white"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -147,20 +154,14 @@
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 px-3 py-1 rounded-full text-xs font-bold uppercase">{{ $subcategory }}</span>
|
||||
<span id="favorite-pill" class="hidden bg-yellow-500/10 text-yellow-300 border border-yellow-500/30 px-3 py-1 rounded-full text-xs font-bold uppercase">Favorite</span>
|
||||
</div>
|
||||
<h1 class="font-display text-5xl font-bold mb-4">{{ $name }}</h1>
|
||||
<p class="text-gray-400 text-lg leading-relaxed">{{ $description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button id="copy-current-emoji-btn" onclick="copyCurrentEmoji()" class="flex-1 bg-brand-ocean hover:bg-brand-oceanSoft text-white force-white font-bold h-14 rounded-xl flex items-center justify-center gap-3 text-lg transition-all shadow-[0_0_20px_rgba(32,83,255,0.35)] hover:shadow-[0_0_30px_rgba(32,83,255,0.55)]">
|
||||
<i data-lucide="copy" class="w-5 h-5 force-white"></i>
|
||||
Copy Emoji
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($supportsTone)
|
||||
<div class="glass-card rounded-xl p-3">
|
||||
<div id="tone-panel" class="glass-card rounded-xl p-3">
|
||||
<div class="text-xs font-mono text-gray-500 mb-2">Skin tone</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" data-tone="off" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">Default</button>
|
||||
@@ -284,7 +285,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div id="toast" class="fixed bottom-10 left-1/2 -translate-x-1/2 translate-y-24 opacity-0 transition-all duration-300 z-50 pointer-events-none">
|
||||
<div id="toast" class="fixed left-1/2 -translate-x-1/2 translate-y-24 opacity-0 transition-all duration-300 z-50 pointer-events-none" style="bottom: calc(env(safe-area-inset-bottom, 0px) + 6rem);">
|
||||
<div class="bg-brand-ocean text-white px-6 py-2 rounded-full font-bold shadow-[0_0_20px_rgba(32,83,255,0.45)] flex items-center gap-2">
|
||||
<i data-lucide="check" class="w-4 h-4"></i>
|
||||
<span id="toast-msg">Copied!</span>
|
||||
@@ -328,9 +329,161 @@
|
||||
@push('scripts')
|
||||
<script>
|
||||
const RECENT_KEY = 'dewemoji_recent';
|
||||
const FAVORITES_KEY = 'dewemoji_favorites';
|
||||
const TONE_STORAGE_KEY = 'dewemoji_skin_tone';
|
||||
const SYMBOL_DEFAULT = @json($symbol);
|
||||
const DETAIL_SLUG = @json($slug);
|
||||
const DETAIL_NAME = @json($name);
|
||||
const DETAIL_CATEGORY = @json($category);
|
||||
const DETAIL_SUBCATEGORY = @json($subcategory);
|
||||
const DETAIL_SUPPORTS_TONE = @json($supportsTone);
|
||||
const DETAIL_VARIANTS_LIST = @json(array_values($toneVariants));
|
||||
const TONE_VARIANTS = @json($toneVariants);
|
||||
const TONE_CHAR_BY_SLUG = {
|
||||
light: '\u{1F3FB}',
|
||||
'medium-light': '\u{1F3FC}',
|
||||
medium: '\u{1F3FD}',
|
||||
'medium-dark': '\u{1F3FE}',
|
||||
dark: '\u{1F3FF}',
|
||||
};
|
||||
const TONE_CPS = new Set([0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF]);
|
||||
const MODIFIABLE_BASES = new Set([
|
||||
0x1F9D1, 0x1F468, 0x1F469, 0x1F466, 0x1F467, 0x1F476,
|
||||
0x1F575, 0x1F93C, 0x26F9, 0x1F3CB, 0x1F93D, 0x1F93E, 0x1F926,
|
||||
]);
|
||||
const NOT_TONEABLE_BASES = new Set([
|
||||
0x1F9BE, 0x1F9BF, 0x1FAC0, 0x1F9E0, 0x1FAC1, 0x1F9B7, 0x1F9B4,
|
||||
0x1F440, 0x1F441, 0x1F445, 0x1F444, 0x1FAE6,
|
||||
0x1F9DE, 0x1F9DF, 0x1F9CC, 0x26F7, 0x1F3C2,
|
||||
]);
|
||||
const NON_TONEABLE_NAME_PATTERNS = [
|
||||
/\bspeaking head\b/i,
|
||||
/\bbust in silhouette\b/i,
|
||||
/\bbusts in silhouette\b/i,
|
||||
/\bfootprints?\b/i,
|
||||
/\bfingerprint\b/i,
|
||||
/\bfamily\b/i,
|
||||
/\bpeople hugging\b/i,
|
||||
/\bpeople with bunny ears\b/i,
|
||||
/\bpeople wrestling\b/i,
|
||||
/\bpeople fencing\b/i,
|
||||
/\bperson fencing\b/i,
|
||||
/\bmen wrestling\b/i,
|
||||
/\bwomen wrestling\b/i,
|
||||
/\bmerman\b/i,
|
||||
/\bmermaid\b/i,
|
||||
/\bdeaf man\b/i,
|
||||
/\bdeaf woman\b/i,
|
||||
/\bman: beard\b/i,
|
||||
/\bwoman: beard\b/i,
|
||||
/\bperson running facing right\b/i,
|
||||
/\bperson walking facing right\b/i,
|
||||
/\bperson kneeling facing right\b/i,
|
||||
];
|
||||
const ROLE_TONEABLE_NAME_PATTERNS = [
|
||||
/\btechnologist\b/i,
|
||||
/\bscientist\b/i,
|
||||
/\boffice worker\b/i,
|
||||
/\bfactory worker\b/i,
|
||||
/\bmechanic\b/i,
|
||||
/\bcook\b/i,
|
||||
/\bfarmer\b/i,
|
||||
/\bjudge\b/i,
|
||||
/\bteacher\b/i,
|
||||
/\bstudent\b/i,
|
||||
/\bhealth worker\b/i,
|
||||
/\bsinger\b/i,
|
||||
/\bastronaut\b/i,
|
||||
/\bfirefighter\b/i,
|
||||
/\bfacepalming\b/i,
|
||||
/\bdancing\b/i,
|
||||
/\bdetective\b/i,
|
||||
/\bfencing\b/i,
|
||||
/\bbouncing ball\b/i,
|
||||
/\blifting weights\b/i,
|
||||
/\bwrestling\b/i,
|
||||
];
|
||||
const TONE_CATEGORY_ALLOWLIST = new Set(['people & body', 'activities']);
|
||||
const ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS = [
|
||||
/\bperson fencing\b/i,
|
||||
/\bmen wrestling\b/i,
|
||||
/\bwomen wrestling\b/i,
|
||||
];
|
||||
const ANDROID_TONE_RENDER_BLOCKLIST_SLUGS = new Set([
|
||||
'person-fencing',
|
||||
'men-wrestling',
|
||||
'women-wrestling',
|
||||
]);
|
||||
const ANDROID_TONE_IMAGE_FALLBACK_SLUGS = new Set([
|
||||
'person-fencing',
|
||||
'men-wrestling',
|
||||
'women-wrestling',
|
||||
]);
|
||||
const IS_ANDROID_RENDERER = (() => {
|
||||
try {
|
||||
if (/Android/i.test(navigator.userAgent || '')) return true;
|
||||
const platform = window.Capacitor?.getPlatform?.();
|
||||
return platform === 'android';
|
||||
} catch (_) {
|
||||
return /Android/i.test(navigator.userAgent || '');
|
||||
}
|
||||
})();
|
||||
|
||||
function twemojiSvgUrlFromCodepoints(cp) {
|
||||
return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`;
|
||||
}
|
||||
|
||||
function notoEmojiSvgUrlFromCodepoints(cp) {
|
||||
return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${String(cp || '').replace(/-/g, '_')}.svg`;
|
||||
}
|
||||
|
||||
function shouldUseDetailToneImageFallback(emojiStr, tone = 'off') {
|
||||
if (!IS_ANDROID_RENDERER) return false;
|
||||
if (!tone || tone === 'off') return false;
|
||||
if (!ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(String(DETAIL_SLUG || '').toLowerCase())) return false;
|
||||
return /\u200D/.test(String(emojiStr || ''));
|
||||
}
|
||||
|
||||
function renderDetailHeroEmoji(emojiStr, tone = 'off') {
|
||||
const hero = document.getElementById('emoji-hero-symbol');
|
||||
if (!hero) return;
|
||||
const glyph = String(emojiStr || '');
|
||||
if (!glyph) {
|
||||
hero.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!shouldUseDetailToneImageFallback(glyph, tone) || !window.twemoji?.convert?.toCodePoint) {
|
||||
hero.textContent = glyph;
|
||||
return;
|
||||
}
|
||||
let cp = '';
|
||||
try {
|
||||
cp = window.twemoji.convert.toCodePoint(glyph);
|
||||
} catch (_) {
|
||||
hero.textContent = glyph;
|
||||
return;
|
||||
}
|
||||
if (!cp) {
|
||||
hero.textContent = glyph;
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.alt = glyph;
|
||||
img.draggable = false;
|
||||
img.decoding = 'async';
|
||||
img.className = 'inline-block align-middle select-none';
|
||||
img.style.width = '1em';
|
||||
img.style.height = '1em';
|
||||
img.style.objectFit = 'contain';
|
||||
img.onerror = () => {
|
||||
img.onerror = () => {
|
||||
hero.textContent = glyph;
|
||||
};
|
||||
img.src = twemojiSvgUrlFromCodepoints(cp);
|
||||
};
|
||||
img.src = notoEmojiSvgUrlFromCodepoints(cp);
|
||||
hero.replaceChildren(img);
|
||||
}
|
||||
let currentDisplayEmoji = SYMBOL_DEFAULT;
|
||||
|
||||
function getStoredTone() {
|
||||
@@ -343,13 +496,17 @@ function setStoredTone(value) {
|
||||
|
||||
function emojiByTone(tone) {
|
||||
if (!tone || tone === 'off') return SYMBOL_DEFAULT;
|
||||
return TONE_VARIANTS[tone] || SYMBOL_DEFAULT;
|
||||
if (!isDetailToneAllowed()) return stripSkinTone(SYMBOL_DEFAULT) || SYMBOL_DEFAULT;
|
||||
const toneChar = TONE_CHAR_BY_SLUG[tone];
|
||||
if (!toneChar) return TONE_VARIANTS[tone] || SYMBOL_DEFAULT;
|
||||
const base = stripSkinTone(SYMBOL_DEFAULT);
|
||||
if (!canApplyToneTo(base)) return base;
|
||||
return applyToneSmart(base, toneChar) || TONE_VARIANTS[tone] || base || SYMBOL_DEFAULT;
|
||||
}
|
||||
|
||||
function applyTone(tone) {
|
||||
currentDisplayEmoji = emojiByTone(tone);
|
||||
const hero = document.getElementById('emoji-hero-symbol');
|
||||
if (hero) hero.textContent = currentDisplayEmoji;
|
||||
renderDetailHeroEmoji(currentDisplayEmoji, tone);
|
||||
document.querySelectorAll('.tone-chip').forEach((chip) => {
|
||||
const active = chip.dataset.tone === tone;
|
||||
chip.classList.toggle('bg-brand-ocean/20', active);
|
||||
@@ -360,22 +517,243 @@ function applyTone(tone) {
|
||||
|
||||
function loadRecent() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
||||
const parsed = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
||||
return Array.isArray(parsed) ? parsed.filter(isRecentEmojiToken) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isRecentEmojiToken(value) {
|
||||
const s = String(value || '').trim();
|
||||
if (!s) return false;
|
||||
if (s.length > 24) return false;
|
||||
if (/[:;&<#\\]/.test(s)) return false;
|
||||
try {
|
||||
return /\p{Extended_Pictographic}/u.test(s);
|
||||
} catch {
|
||||
return /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(s);
|
||||
}
|
||||
}
|
||||
|
||||
function isDetailNameNonToneable() {
|
||||
return NON_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''));
|
||||
}
|
||||
|
||||
function isDetailRoleToneable() {
|
||||
return ROLE_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''));
|
||||
}
|
||||
|
||||
function isDetailToneAllowed() {
|
||||
if (!DETAIL_SUPPORTS_TONE) return false;
|
||||
const category = String(DETAIL_CATEGORY || '').toLowerCase();
|
||||
if (!TONE_CATEGORY_ALLOWLIST.has(category)) return false;
|
||||
if (isDetailNameNonToneable()) return false;
|
||||
if (IS_ANDROID_RENDERER) {
|
||||
const slug = String(DETAIL_SLUG || '').toLowerCase();
|
||||
const usesImageFallback = ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug);
|
||||
if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_SLUGS.has(slug)) return false;
|
||||
if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''))) return false;
|
||||
}
|
||||
const base = stripSkinTone(SYMBOL_DEFAULT);
|
||||
if (!base || !canApplyToneTo(base)) return false;
|
||||
|
||||
const name = String(DETAIL_NAME || '').toLowerCase();
|
||||
const looksGenderedRole =
|
||||
category === 'people & body' &&
|
||||
(name.startsWith('man ') || name.startsWith('woman ') || /[:\-,]\s*(man|woman)\b/.test(name));
|
||||
if (looksGenderedRole && !isDetailRoleToneable()) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function stripSkinTone(emojiChar) {
|
||||
return String(emojiChar || '').replace(/[\u{1F3FB}-\u{1F3FF}]/gu, '');
|
||||
}
|
||||
|
||||
function containsZWJ(s) {
|
||||
return /\u200D/.test(String(s || ''));
|
||||
}
|
||||
|
||||
function countHumans(s) {
|
||||
let n = 0;
|
||||
for (const ch of Array.from(String(s || ''))) {
|
||||
const cp = ch.codePointAt(0);
|
||||
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function canApplyToneTo(s) {
|
||||
if (!s) return false;
|
||||
try {
|
||||
const cp0 = String(s).codePointAt(0);
|
||||
if (NOT_TONEABLE_BASES.has(cp0)) return false;
|
||||
} catch (_) {}
|
||||
if (!containsZWJ(s)) return true;
|
||||
return countHumans(s) <= 2;
|
||||
}
|
||||
|
||||
function toCodePoints(str) {
|
||||
const out = [];
|
||||
for (const ch of String(str || '')) out.push(ch.codePointAt(0));
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromCodePoints(arr) {
|
||||
return String.fromCodePoint(...arr);
|
||||
}
|
||||
|
||||
function applyToneSmart(emojiChar, toneChar) {
|
||||
if (!emojiChar || !toneChar) return emojiChar;
|
||||
const toneCp = String(toneChar).codePointAt(0);
|
||||
if (!TONE_CPS.has(toneCp)) return emojiChar;
|
||||
|
||||
const cps = toCodePoints(emojiChar);
|
||||
for (const cp of cps) {
|
||||
if (TONE_CPS.has(cp)) return emojiChar;
|
||||
}
|
||||
|
||||
const VS16 = 0xFE0F;
|
||||
const ZWJ = 0x200D;
|
||||
const idxs = [];
|
||||
for (let i = 0; i < cps.length; i += 1) {
|
||||
if (MODIFIABLE_BASES.has(cps[i])) idxs.push(i);
|
||||
}
|
||||
|
||||
if (idxs.length === 0) {
|
||||
if (cps.includes(ZWJ)) return emojiChar;
|
||||
let pos = 1;
|
||||
let rightStart = pos;
|
||||
if (cps[1] === VS16) {
|
||||
rightStart = 2;
|
||||
}
|
||||
const left = cps.slice(0, pos);
|
||||
left.push(toneCp);
|
||||
return fromCodePoints(left.concat(cps.slice(rightStart)));
|
||||
}
|
||||
|
||||
if (idxs.length === 2) {
|
||||
const out = cps.slice();
|
||||
const insertAfter = (baseIdx) => {
|
||||
let pos = baseIdx + 1;
|
||||
if (out[pos] === VS16) out.splice(pos, 1);
|
||||
out.splice(pos, 0, toneCp);
|
||||
};
|
||||
insertAfter(idxs[1]);
|
||||
insertAfter(idxs[0]);
|
||||
return fromCodePoints(out);
|
||||
}
|
||||
|
||||
let pos = idxs[0] + 1;
|
||||
let rightStart = pos;
|
||||
if (cps[pos] === VS16) {
|
||||
rightStart = pos + 1;
|
||||
}
|
||||
const left = cps.slice(0, pos);
|
||||
left.push(toneCp);
|
||||
return fromCodePoints(left.concat(cps.slice(rightStart)));
|
||||
}
|
||||
|
||||
function saveRecent(items) {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(items.slice(0, 8)));
|
||||
const clean = items.filter(isRecentEmojiToken);
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(clean.slice(0, 8)));
|
||||
}
|
||||
|
||||
function addRecent(emoji) {
|
||||
if (!isRecentEmojiToken(emoji)) return;
|
||||
const curr = loadRecent().filter((e) => e !== emoji);
|
||||
curr.unshift(emoji);
|
||||
saveRecent(curr);
|
||||
}
|
||||
|
||||
function loadFavorites() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(FAVORITES_KEY) || '[]');
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
slug: String(item.slug || '').trim(),
|
||||
emoji: String(item.emoji || '').trim(),
|
||||
name: String(item.name || '').trim(),
|
||||
category: String(item.category || '').trim(),
|
||||
subcategory: String(item.subcategory || '').trim(),
|
||||
supports_skin_tone: Boolean(item.supports_skin_tone),
|
||||
variants: Array.isArray(item.variants) ? item.variants : [],
|
||||
}))
|
||||
.filter((item) => item.slug !== '' && isRecentEmojiToken(item.emoji));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveFavorites(items) {
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items.slice(0, 24)));
|
||||
}
|
||||
|
||||
function isCurrentFavorite() {
|
||||
return loadFavorites().some((item) => item.slug === DETAIL_SLUG);
|
||||
}
|
||||
|
||||
function renderFavoriteButton() {
|
||||
const btn = document.getElementById('favorite-toggle-btn');
|
||||
const icon = document.getElementById('favorite-toggle-icon');
|
||||
const label = document.getElementById('favorite-toggle-label');
|
||||
const pill = document.getElementById('favorite-pill');
|
||||
if (!btn || !icon || !label) return;
|
||||
const active = isCurrentFavorite();
|
||||
icon.textContent = active ? '★' : '☆';
|
||||
icon.classList.toggle('text-yellow-300', true);
|
||||
icon.classList.toggle('text-gray-300', !active);
|
||||
label.textContent = active ? 'Favorited' : 'Add Favorite';
|
||||
btn.title = active ? 'Remove Favorite' : 'Add Favorite';
|
||||
btn.setAttribute('aria-label', active ? 'Remove Favorite' : 'Add Favorite');
|
||||
btn.classList.toggle('border-yellow-400/30', active);
|
||||
btn.classList.toggle('bg-yellow-400/10', active);
|
||||
if (pill) {
|
||||
pill.classList.toggle('hidden', !active);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCurrentFavorite() {
|
||||
const current = loadFavorites();
|
||||
const remaining = current.filter((item) => item.slug !== DETAIL_SLUG);
|
||||
const isRemoving = remaining.length !== current.length;
|
||||
if (isRemoving) {
|
||||
saveFavorites(remaining);
|
||||
renderFavoriteButton();
|
||||
if (typeof showToast === 'function') showToast('Removed from favorites');
|
||||
else showDetailToast('Removed from favorites');
|
||||
return false;
|
||||
}
|
||||
remaining.unshift({
|
||||
slug: DETAIL_SLUG,
|
||||
emoji: currentDisplayEmoji,
|
||||
name: DETAIL_NAME,
|
||||
category: DETAIL_CATEGORY,
|
||||
subcategory: DETAIL_SUBCATEGORY,
|
||||
supports_skin_tone: Boolean(DETAIL_SUPPORTS_TONE),
|
||||
variants: Array.isArray(DETAIL_VARIANTS_LIST) ? DETAIL_VARIANTS_LIST : [],
|
||||
});
|
||||
saveFavorites(remaining);
|
||||
renderFavoriteButton();
|
||||
if (typeof showToast === 'function') showToast('Added to favorites');
|
||||
else showDetailToast('Added to favorites');
|
||||
return true;
|
||||
}
|
||||
|
||||
function showDetailToast(message) {
|
||||
const toast = document.getElementById('toast');
|
||||
const msg = document.getElementById('toast-msg');
|
||||
if (!toast || !msg) return;
|
||||
msg.innerText = message;
|
||||
toast.classList.remove('translate-y-24', 'opacity-0');
|
||||
setTimeout(() => {
|
||||
toast.classList.add('translate-y-24', 'opacity-0');
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function copyCurrentEmoji() {
|
||||
copyToClipboard(currentDisplayEmoji);
|
||||
}
|
||||
@@ -383,13 +761,7 @@ function copyCurrentEmoji() {
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
addRecent(text);
|
||||
const toast = document.getElementById('toast');
|
||||
const msg = document.getElementById('toast-msg');
|
||||
msg.innerText = `Copied ${text}`;
|
||||
toast.classList.remove('translate-y-24', 'opacity-0');
|
||||
setTimeout(() => {
|
||||
toast.classList.add('translate-y-24', 'opacity-0');
|
||||
}, 1500);
|
||||
showDetailToast(`Copied ${text}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -400,6 +772,12 @@ document.addEventListener('keydown', (e) => {
|
||||
});
|
||||
|
||||
(() => {
|
||||
const tonePanel = document.getElementById('tone-panel');
|
||||
if (!isDetailToneAllowed()) {
|
||||
if (tonePanel) tonePanel.classList.add('hidden');
|
||||
applyTone('off');
|
||||
return;
|
||||
}
|
||||
const initialTone = getStoredTone();
|
||||
applyTone(initialTone);
|
||||
document.querySelectorAll('.tone-chip').forEach((chip) => {
|
||||
@@ -411,6 +789,9 @@ document.addEventListener('keydown', (e) => {
|
||||
});
|
||||
})();
|
||||
|
||||
document.getElementById('favorite-toggle-btn')?.addEventListener('click', toggleCurrentFavorite);
|
||||
renderFavoriteButton();
|
||||
|
||||
// Treat opening the single-emoji page as a "recently viewed emoji" event.
|
||||
addRecent(currentDisplayEmoji);
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ Route::get('/emoji/{slug}', [SiteController::class, 'emojiDetail'])->name('emoji
|
||||
Route::get('/pricing', [SiteController::class, 'pricing'])->name('pricing');
|
||||
Route::post('/pricing/currency', [SiteController::class, 'setPricingCurrency'])->name('pricing.currency');
|
||||
Route::get('/download', [SiteController::class, 'download'])->name('download');
|
||||
Route::get('/downloads/version.json', [SiteController::class, 'downloadVersionJson'])->name('downloads.version');
|
||||
Route::get('/downloads/dewemoji-latest.apk', [SiteController::class, 'downloadLatestApk'])->name('downloads.latest-apk');
|
||||
Route::get('/.well-known/assetlinks.json', [SiteController::class, 'assetLinks'])->name('assetlinks');
|
||||
Route::get('/support', [SiteController::class, 'support'])->name('support');
|
||||
Route::get('/privacy', [SiteController::class, 'privacy'])->name('privacy');
|
||||
Route::get('/terms', [SiteController::class, 'terms'])->name('terms');
|
||||
|
||||
@@ -11,6 +11,13 @@ class SitePagesTest extends TestCase
|
||||
parent::setUp();
|
||||
|
||||
config()->set('dewemoji.data_path', base_path('tests/Fixtures/emojis.fixture.json'));
|
||||
config()->set('dewemoji.apk_release.enabled', true);
|
||||
config()->set('dewemoji.apk_release.r2_public_base_url', 'https://downloads.example.com');
|
||||
config()->set('dewemoji.apk_release.r2_keys.latest_apk', 'apk/dewemoji-latest.apk');
|
||||
config()->set('dewemoji.apk_release.r2_keys.version_json', 'apk/version.json');
|
||||
config()->set('dewemoji.apk_release.assetlinks.fingerprints', [
|
||||
'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_core_pages_are_available(): void
|
||||
@@ -39,4 +46,30 @@ class SitePagesTest extends TestCase
|
||||
{
|
||||
$this->get('/emoji/unknown-slug')->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_download_redirect_endpoints_are_available(): void
|
||||
{
|
||||
$this->get('/downloads/version.json')
|
||||
->assertStatus(302)
|
||||
->assertRedirect('https://downloads.example.com/apk/version.json');
|
||||
|
||||
$this->get('/downloads/dewemoji-latest.apk')
|
||||
->assertStatus(302)
|
||||
->assertRedirect('https://downloads.example.com/apk/dewemoji-latest.apk');
|
||||
}
|
||||
|
||||
public function test_assetlinks_endpoint_is_available(): void
|
||||
{
|
||||
$this->get('/.well-known/assetlinks.json')
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
[
|
||||
'relation' => ['delegate_permission/common.handle_all_urls'],
|
||||
'target' => [
|
||||
'namespace' => 'android_app',
|
||||
'package_name' => 'com.dewemoji.app',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
# Billing Cooldown Runtime Checklist (Staging)
|
||||
|
||||
## Scope
|
||||
Validate these behaviors end-to-end:
|
||||
1. `pending_cooldown` lock on new checkout (120s)
|
||||
2. Continue pending payment via `Pay` button
|
||||
3. PayPal/Pakasir webhook delay handling
|
||||
4. Auto-status transition from `pending` to `paid`
|
||||
|
||||
## Preconditions
|
||||
1. Staging is deployed with latest billing changes.
|
||||
2. Config is refreshed:
|
||||
- `php artisan optimize:clear`
|
||||
- `php artisan config:cache`
|
||||
3. Env includes:
|
||||
- `DEWEMOJI_CHECKOUT_PENDING_COOLDOWN=120`
|
||||
4. Webhooks are configured and reachable:
|
||||
- PayPal webhook endpoint
|
||||
- Pakasir webhook endpoint
|
||||
5. Test account available (verified email, logged in).
|
||||
|
||||
## Quick Observability
|
||||
1. Browser devtools open (`Network` tab).
|
||||
2. Keep Billing page open in another tab for status checks.
|
||||
3. Optional server logs tail:
|
||||
- `tail -f storage/logs/laravel.log`
|
||||
|
||||
## Test Matrix
|
||||
|
||||
### A. Cooldown Lock (PayPal)
|
||||
1. Start Personal checkout in USD.
|
||||
2. Before webhook settles, try starting another checkout from Pricing.
|
||||
3. Expected:
|
||||
- API returns `409` with `error: pending_cooldown`.
|
||||
- UI shows wait message with countdown (`retry_after` seconds).
|
||||
- Checkout CTA stays disabled until countdown reaches 0.
|
||||
|
||||
### B. Cooldown Lock (Pakasir)
|
||||
1. Start Personal checkout in IDR (QRIS modal opens).
|
||||
2. Close/cancel modal only if needed for this test case.
|
||||
3. Immediately try another new checkout from Pricing.
|
||||
4. Expected:
|
||||
- API returns `409 pending_cooldown`.
|
||||
- UI countdown appears and blocks new checkout.
|
||||
|
||||
### C. Continue Pending (PayPal)
|
||||
1. Create a PayPal pending payment.
|
||||
2. Go to `/dashboard/billing`.
|
||||
3. Click `Pay` on pending row.
|
||||
4. Expected:
|
||||
- Redirect to PayPal approve URL.
|
||||
- No new payment row created just for resume action.
|
||||
|
||||
### D. Continue Pending (Pakasir)
|
||||
1. Create a Pakasir pending payment.
|
||||
2. Go to `/dashboard/billing`.
|
||||
3. Click `Pay` on pending row.
|
||||
4. Expected:
|
||||
- QR modal opens with amount + expiry.
|
||||
- Polling runs and closes modal when status becomes paid.
|
||||
|
||||
### E. Webhook Delay UX
|
||||
1. Complete payment at provider.
|
||||
2. Observe app while webhook is delayed.
|
||||
3. Expected:
|
||||
- Status may remain `pending` briefly.
|
||||
- User can continue pending (`Pay` button).
|
||||
- After webhook/poll settles: status updates to `paid`.
|
||||
|
||||
### F. Pending Timeout to New Checkout
|
||||
1. Keep a payment pending beyond cooldown (`>=120s`).
|
||||
2. Try new checkout from Pricing.
|
||||
3. Expected:
|
||||
- New checkout is allowed again.
|
||||
- Existing behavior for replacing/canceling pending remains intact.
|
||||
|
||||
### G. Edge Responses
|
||||
1. Try `Pay` on a row that is no longer pending (race condition).
|
||||
2. Expected:
|
||||
- UI handles response (`payment_not_pending`) and refreshes state safely.
|
||||
3. If pending QR is expired:
|
||||
- Expected `payment_expired`, prompt to start new checkout.
|
||||
|
||||
## API Assertions (Network)
|
||||
Check response payloads:
|
||||
1. Cooldown block:
|
||||
```json
|
||||
{
|
||||
"error": "pending_cooldown",
|
||||
"retry_after": 87,
|
||||
"pending_payment_id": 123,
|
||||
"provider": "paypal"
|
||||
}
|
||||
```
|
||||
2. Resume PayPal:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"mode": "redirect",
|
||||
"approve_url": "https://..."
|
||||
}
|
||||
```
|
||||
3. Resume Pakasir:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"mode": "qris",
|
||||
"payment_number": "...",
|
||||
"order_id": "DW-...",
|
||||
"expired_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## Pass/Fail Sheet
|
||||
| Check | Result | Notes |
|
||||
|---|---|---|
|
||||
| A. PayPal cooldown lock | | |
|
||||
| B. Pakasir cooldown lock | | |
|
||||
| C. Resume pending PayPal | | |
|
||||
| D. Resume pending Pakasir | | |
|
||||
| E. Webhook delay UX | | |
|
||||
| F. New checkout after cooldown | | |
|
||||
| G. Edge response handling | | |
|
||||
|
||||
## Exit Criteria
|
||||
Release-ready when:
|
||||
1. All rows above are `PASS`.
|
||||
2. No uncaught JS errors in console.
|
||||
3. No unexpected 5xx in network log during flow.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Billing Mode Switch (`sandbox` -> `live`)
|
||||
|
||||
This project supports two license verification modes via env:
|
||||
|
||||
- `DEWEMOJI_BILLING_MODE=sandbox`
|
||||
Any non-empty license key is treated as valid Pro.
|
||||
- `DEWEMOJI_BILLING_MODE=live`
|
||||
Key must pass live validation rules (`DEWEMOJI_LICENSE_ACCEPT_ALL`, `DEWEMOJI_PRO_KEYS`, or provider validation).
|
||||
|
||||
## Recommended local setup
|
||||
|
||||
Use sandbox while building:
|
||||
|
||||
```env
|
||||
DEWEMOJI_BILLING_MODE=sandbox
|
||||
DEWEMOJI_LICENSE_ACCEPT_ALL=false
|
||||
DEWEMOJI_PRO_KEYS=
|
||||
```
|
||||
|
||||
## Staging/live setup
|
||||
|
||||
Use live mode:
|
||||
|
||||
```env
|
||||
DEWEMOJI_BILLING_MODE=live
|
||||
DEWEMOJI_LICENSE_ACCEPT_ALL=false
|
||||
DEWEMOJI_PRO_KEYS=key_1,key_2,key_3
|
||||
DEWEMOJI_VERIFY_CACHE_TTL=300
|
||||
DEWEMOJI_GUMROAD_ENABLED=true
|
||||
DEWEMOJI_GUMROAD_PRODUCT_IDS=prod_abc123
|
||||
DEWEMOJI_MAYAR_ENABLED=false
|
||||
DEWEMOJI_MAYAR_API_BASE=https://api.mayar.id
|
||||
DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify
|
||||
DEWEMOJI_MAYAR_SECRET_KEY=
|
||||
```
|
||||
|
||||
## Provider notes
|
||||
|
||||
- Gumroad validation uses configured `DEWEMOJI_GUMROAD_VERIFY_URL` + first `DEWEMOJI_GUMROAD_PRODUCT_IDS`.
|
||||
- Mayar validation uses `DEWEMOJI_MAYAR_VERIFY_URL` + `DEWEMOJI_MAYAR_API_KEY`.
|
||||
- Or use `DEWEMOJI_MAYAR_API_BASE` + `DEWEMOJI_MAYAR_ENDPOINT_VERIFY` + `DEWEMOJI_MAYAR_SECRET_KEY`.
|
||||
- For local QA (no external billing call), you can define:
|
||||
- `DEWEMOJI_GUMROAD_TEST_KEYS=dev_key_1,dev_key_2`
|
||||
- `DEWEMOJI_MAYAR_TEST_KEYS=dev_key_3`
|
||||
|
||||
## API endpoints affected
|
||||
|
||||
- `POST /v1/license/verify`
|
||||
- `POST /v1/license/activate`
|
||||
- `POST /v1/license/deactivate`
|
||||
- Tier-aware API access such as `GET /v1/emojis` (free/pro behavior)
|
||||
|
||||
## Notes
|
||||
|
||||
- Current provider integration is baseline and safe-fallback (`false` on network/API mismatch).
|
||||
- Keep `DEWEMOJI_PRO_KEYS` for emergency fallback during migration cutover.
|
||||
- `POST /v1/license/verify` includes provider fields on success: `source`, `plan`, `product_id`, `expires_at`.
|
||||
- Invalid live checks include `details.gumroad` and `details.mayar` for diagnostics.
|
||||
@@ -1,124 +0,0 @@
|
||||
# Dewemoji Community Plan (Private‑First → Public Consensus)
|
||||
|
||||
This plan matches the core purpose: **help people find emojis with their own words, in any language**, and let the community optionally promote keywords into global search.
|
||||
|
||||
## Core principles
|
||||
|
||||
- **Private first:** users can store any keyword for their own use.
|
||||
- **Public by choice:** users can propose a keyword to the public pool.
|
||||
- **Consensus‑based:** public keywords only become searchable after votes.
|
||||
- **Language‑friendly:** keywords can be in any language/script (not only Latin).
|
||||
- **Safety‑aware:** moderation applies only to public proposals (not private).
|
||||
|
||||
## Current language reality
|
||||
|
||||
- Today the dataset only includes **EN + ID** keywords for all emojis.
|
||||
- Community should be designed to add **other languages** over time.
|
||||
|
||||
## Two‑layer keyword model
|
||||
|
||||
### 1) Private keywords (personal)
|
||||
- Stored per user (or device if not logged in).
|
||||
- Any term is allowed, even if not “correct.”
|
||||
- Used only for that user’s search + recall.
|
||||
- No moderation needed.
|
||||
- Created and managed in the **user dashboard** (not on emoji detail pages).
|
||||
- Must be **used locally first** before being eligible for public proposal.
|
||||
|
||||
### 2) Public keywords (community)
|
||||
- Users can propose **one of their own private keywords** to be public **from the dashboard**.
|
||||
- Emoji detail pages only **display and vote** on public proposals; no direct proposal UI there.
|
||||
- Once the proposal passes the threshold, it becomes globally searchable.
|
||||
|
||||
## Public keyword lifecycle
|
||||
|
||||
```
|
||||
private → public_pending → public
|
||||
```
|
||||
|
||||
Suggested rules:
|
||||
- **public_pending** shown publicly with vote controls.
|
||||
- **public** if it reaches **≥ 5 upvotes** (your rule).
|
||||
- Optional future: auto‑reject with heavy downvotes or time decay.
|
||||
|
||||
## Voting model
|
||||
|
||||
- 1 vote per user per keyword.
|
||||
- Score = upvotes − downvotes.
|
||||
- Promotion rule: **>= 5 upvotes** (simple and clear).
|
||||
- Optional future: trust‑weighted votes.
|
||||
|
||||
## Moderation model
|
||||
|
||||
- Moderation applies only to **public proposals**.
|
||||
- Private keywords are always allowed.
|
||||
- Blocking rules:
|
||||
- disallowed words
|
||||
- mismatched emoji meaning
|
||||
- language mismatch (optional)
|
||||
|
||||
## UX flow (simple)
|
||||
|
||||
1) User adds private keyword.
|
||||
2) User uses it locally (personal search/recall).
|
||||
3) User clicks “Make Public” from the dashboard.
|
||||
4) Keyword appears on emoji detail page as pending.
|
||||
4) Community votes.
|
||||
5) Reaches 5 upvotes → becomes public search term.
|
||||
|
||||
## API endpoints (clean, minimal set)
|
||||
|
||||
Auth:
|
||||
- `POST /v1/contrib/auth/request`
|
||||
- `POST /v1/contrib/auth/verify`
|
||||
|
||||
Keyword actions:
|
||||
- `POST /v1/contrib/suggest`
|
||||
- `POST /v1/contrib/make-public`
|
||||
- `POST /v1/contrib/vote`
|
||||
- `GET /v1/contrib/list?emoji=...`
|
||||
- `GET /v1/contrib/search?q=...`
|
||||
|
||||
Moderation:
|
||||
- `GET /v1/keywords/pending` (admin/moderation)
|
||||
|
||||
## Data model (minimum)
|
||||
|
||||
- `emoji_keywords` (keyword, lang, status, visibility, owner_user_id)
|
||||
- `keyword_votes` (keyword_id, user_id, vote)
|
||||
- `moderation_events`
|
||||
- `users` / `magic_links`
|
||||
|
||||
## Optional recommendations (future‑safe)
|
||||
|
||||
### A) Multilingual expansion
|
||||
- Encourage new languages by highlighting “empty language slots.”
|
||||
- Allow region variants (e.g., `en-US` vs `en-GB`).
|
||||
|
||||
### B) Personal dictionary export
|
||||
- Export private keywords per user.
|
||||
|
||||
### C) Trust tiers
|
||||
- Contributors with good history can fast‑track proposals.
|
||||
|
||||
### D) Admin review console
|
||||
- Moderators can approve/reject quickly.
|
||||
|
||||
### E) Abuse prevention
|
||||
- Rate limit public proposals + voting.
|
||||
- Use Turnstile only for public actions.
|
||||
|
||||
### F) Search blending
|
||||
- Private keywords weighted highest for the owner.
|
||||
- Public keywords weighted lower but global.
|
||||
|
||||
### G) “My Words” UI
|
||||
- Show user’s private keywords in the **dashboard**.
|
||||
- One‑tap “Make Public” in the dashboard (not on detail page).
|
||||
|
||||
## Recommended rollout
|
||||
|
||||
1) Private keywords (local only).
|
||||
2) Public proposals + votes (no AI).
|
||||
3) Basic moderation + rate limits.
|
||||
4) AI guard + trust tiers.
|
||||
@@ -1,74 +0,0 @@
|
||||
# Current Local Database (SQLite)
|
||||
|
||||
This describes what exists **right now** in the rebuild app (`app/database/database.sqlite`).
|
||||
|
||||
## Local DB engine
|
||||
|
||||
- SQLite (file: `app/database/database.sqlite`)
|
||||
|
||||
## Tables present
|
||||
|
||||
Framework defaults:
|
||||
- `cache`
|
||||
- `cache_locks`
|
||||
- `failed_jobs`
|
||||
- `job_batches`
|
||||
- `jobs`
|
||||
- `migrations`
|
||||
- `password_reset_tokens`
|
||||
- `sessions`
|
||||
- `users`
|
||||
|
||||
Dewemoji core tables:
|
||||
- `licenses`
|
||||
- `license_activations`
|
||||
- `usage_logs`
|
||||
|
||||
## Current row counts (local)
|
||||
|
||||
- `emojis`: 2131
|
||||
- `emoji_keywords`: 13420
|
||||
- `emoji_aliases`: 0
|
||||
- `emoji_shortcodes`: 0
|
||||
- `licenses`: 7
|
||||
- `license_activations`: 1
|
||||
- `usage_logs`: 88
|
||||
- `ai_guard_logs`: 54
|
||||
- `ai_judgments`: 0
|
||||
- `ai_lang_cache`: 6
|
||||
- `ai_provider_usage`: 4
|
||||
- `legacy_users`: 0
|
||||
- `legacy_sessions`: 0
|
||||
|
||||
## What is *not* in local DB yet (from live SQL)
|
||||
|
||||
From `dewemoji-live-backend/dewemojiAPI_DB.sql`, these tables exist in live but are **still empty locally**:
|
||||
|
||||
- `emoji_aliases`
|
||||
- `emoji_shortcodes`
|
||||
- `ai_judgments`
|
||||
- `legacy_users` (live users)
|
||||
- `legacy_sessions` (live sessions)
|
||||
|
||||
## Why emojis still work locally
|
||||
|
||||
The rebuild app currently reads emojis from a **JSON dataset** (not DB):
|
||||
- `app/data/emojis.json`
|
||||
|
||||
That’s why the UI works even though emoji tables aren’t present yet.
|
||||
|
||||
## Next step (if you want to migrate live SQL)
|
||||
|
||||
Migrations + importer are now in place. To sync everything locally (SQLite), run:
|
||||
|
||||
```bash
|
||||
cd /Users/dwindown/Developments/dewemoji/app
|
||||
php artisan migrate
|
||||
php artisan dewemoji:import-live-sql /Users/dwindown/Developments/dewemoji-live-backend/dewemojiAPI_DB.sql --truncate
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Live `users` + `sessions` go into `legacy_users` + `legacy_sessions` (so Laravel auth/session tables stay safe).
|
||||
- Licenses/activations/usage_logs are mapped into the current tables for parity.
|
||||
|
||||
Just tell me which subset to migrate first.
|
||||
@@ -1,66 +1,53 @@
|
||||
# Dewemoji Live Deployment Walkthrough
|
||||
# Dewemoji Operations Runbook
|
||||
|
||||
This is the production rollout checklist for the `dewemoji` app.
|
||||
This is the single operational guide for local, staging, and production workflows.
|
||||
|
||||
Use this in order:
|
||||
1. Prepare env
|
||||
2. Deploy code
|
||||
3. Run post-deploy commands
|
||||
4. Ensure admin access
|
||||
5. Verify billing/webhooks/search/auth
|
||||
## 1) Environment model
|
||||
|
||||
---
|
||||
|
||||
## 1) Pre-Deploy (Env + Infra)
|
||||
|
||||
Set these in live environment first:
|
||||
### Local (safe default)
|
||||
|
||||
```env
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://your-live-domain.com
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://127.0.0.1:8000
|
||||
DB_CONNECTION=sqlite
|
||||
DEWEMOJI_BILLING_MODE=sandbox
|
||||
DEWEMOJI_LICENSE_ACCEPT_ALL=false
|
||||
DEWEMOJI_ALLOWED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000
|
||||
```
|
||||
|
||||
Core requirements:
|
||||
1. Database points to live DB (`DB_*`).
|
||||
2. `APP_KEY` is set and stable.
|
||||
3. `SESSION_DRIVER`, `CACHE_STORE`, `QUEUE_CONNECTION` configured.
|
||||
4. Billing provider secrets are set (PayPal/Pakasir).
|
||||
5. Allowed origins include live domain.
|
||||
### Staging / Production
|
||||
|
||||
Recommended billing cooldown config:
|
||||
- Use `DEWEMOJI_BILLING_MODE=live`
|
||||
- Configure real DB + provider credentials
|
||||
- Keep `DEWEMOJI_LICENSE_ACCEPT_ALL=false`
|
||||
|
||||
For full production variable template, use `production-env.md`.
|
||||
|
||||
## 2) Live deployment sequence
|
||||
|
||||
### Pre-deploy
|
||||
|
||||
1. Confirm env values are set in server/Coolify.
|
||||
2. Confirm webhook URLs are reachable:
|
||||
- `https://<domain>/v1/paypal/webhook`
|
||||
- `https://<domain>/webhooks/pakasir`
|
||||
3. Recommended:
|
||||
|
||||
```env
|
||||
DEWEMOJI_BILLING_PENDING_COOLDOWN_SECONDS=120
|
||||
```
|
||||
|
||||
Webhook URLs:
|
||||
1. PayPal webhook: `https://your-live-domain.com/v1/paypal/webhook`
|
||||
2. Pakasir webhook: `https://your-live-domain.com/webhooks/pakasir`
|
||||
|
||||
---
|
||||
|
||||
## 2) Deploy Code
|
||||
|
||||
From your server app directory (example `/var/www/html`):
|
||||
### Deploy code
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git checkout main
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
Install dependencies if needed:
|
||||
|
||||
```bash
|
||||
composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Post-Deploy Commands (Required)
|
||||
|
||||
Run in this exact sequence:
|
||||
### Post-deploy (required order)
|
||||
|
||||
```bash
|
||||
php artisan optimize:clear
|
||||
@@ -68,38 +55,23 @@ php artisan migrate --force
|
||||
php artisan config:cache
|
||||
```
|
||||
|
||||
Optional but recommended:
|
||||
Optional:
|
||||
|
||||
```bash
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
```
|
||||
|
||||
If you use queue workers:
|
||||
|
||||
```bash
|
||||
php artisan queue:restart
|
||||
```
|
||||
|
||||
Check migration status:
|
||||
## 3) Ensure admin access
|
||||
|
||||
```bash
|
||||
php artisan migrate:status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Ensure Admin User
|
||||
|
||||
Admin access is role-based (`users.role = admin`).
|
||||
|
||||
### Option A: Promote existing user (recommended)
|
||||
Promote existing user:
|
||||
|
||||
```bash
|
||||
php artisan tinker --execute="\App\Models\User::where('email','dewemoji@gmail.com')->update(['role'=>'admin']);"
|
||||
```
|
||||
|
||||
### Option B: Create admin user if missing
|
||||
Or create missing admin:
|
||||
|
||||
```bash
|
||||
php artisan tinker --execute="
|
||||
@@ -112,43 +84,30 @@ php artisan tinker --execute="
|
||||
"
|
||||
```
|
||||
|
||||
Verify:
|
||||
## 4) Live smoke tests
|
||||
|
||||
### Core routes
|
||||
|
||||
1. `/`
|
||||
2. `/emoji/grinning-face`
|
||||
3. `/pricing`
|
||||
4. `/api-docs`
|
||||
5. `/support`
|
||||
6. `/privacy`
|
||||
7. `/terms`
|
||||
8. `/robots.txt`
|
||||
9. `/sitemap.xml`
|
||||
|
||||
### API checks
|
||||
|
||||
```bash
|
||||
php artisan tinker --execute="dump(\App\Models\User::where('email','dewemoji@gmail.com')->first(['id','email','role','tier']));"
|
||||
BASE=https://<domain>/v1
|
||||
curl -s "$BASE/health" | jq .
|
||||
curl -s "$BASE/categories" | jq 'keys | length'
|
||||
curl -s "$BASE/emojis?q=love&limit=5" | jq '.items | length'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Smoke Test Checklist (Live)
|
||||
|
||||
### A. Core App
|
||||
1. Login/register works over HTTPS with no insecure form warnings.
|
||||
2. Dashboard loads.
|
||||
3. Discover search returns emojis.
|
||||
4. Emoji detail page loads.
|
||||
|
||||
### B. Skin Tone
|
||||
1. Discover: change skin tone selector and verify toneable emoji changes.
|
||||
2. Detail: tone chips update hero emoji and copy behavior.
|
||||
3. Refresh page: tone preference persists.
|
||||
|
||||
### C. Account + Keywords
|
||||
1. Free account can create up to active limit.
|
||||
2. Active/inactive keyword behavior reflected in search.
|
||||
3. Private keyword search appears in Discover after creation.
|
||||
|
||||
### D. Billing
|
||||
1. PayPal checkout starts and returns.
|
||||
2. Pakasir QRIS starts and modal polls.
|
||||
3. Pending payment can be resumed.
|
||||
4. Cooldown prevents immediate repeated checkout spam.
|
||||
|
||||
### E. Webhooks
|
||||
1. PayPal event recorded and payment status updates.
|
||||
2. Pakasir event recorded and payment status updates.
|
||||
|
||||
Useful checks:
|
||||
### Billing and webhook checks
|
||||
|
||||
```bash
|
||||
tail -n 200 storage/logs/laravel.log
|
||||
@@ -156,39 +115,86 @@ php artisan tinker --execute="dump(\App\Models\WebhookEvent::latest()->take(10)-
|
||||
php artisan tinker --execute="dump(\App\Models\Payment::latest()->take(10)->get(['id','provider','plan_code','status','created_at'])->toArray());"
|
||||
```
|
||||
|
||||
---
|
||||
## 5) Billing runtime validation (staging)
|
||||
|
||||
## 6) Optional: PayPal Plan Sync (Admin)
|
||||
Verify these behaviors end-to-end:
|
||||
|
||||
From admin dashboard:
|
||||
1. Open pricing admin page.
|
||||
2. Click sync PayPal plans.
|
||||
3. Confirm plan IDs are written and no 500.
|
||||
1. pending cooldown lock on repeat checkout attempts (`409 pending_cooldown`)
|
||||
2. resume pending payment via dashboard `Pay`
|
||||
3. webhook delay handling (`pending` -> `paid` transition)
|
||||
4. race/edge handling (`payment_not_pending`, `payment_expired`)
|
||||
|
||||
If there is failure, check:
|
||||
Minimum assertions:
|
||||
|
||||
- cooldown response includes `retry_after`
|
||||
- resume PayPal returns `mode=redirect` + `approve_url`
|
||||
- resume Pakasir returns `mode=qris` with expiry data
|
||||
|
||||
## 6) Staging SQL sync
|
||||
|
||||
Set MySQL connection env, then run in container:
|
||||
|
||||
```bash
|
||||
tail -n 300 storage/logs/laravel.log | grep -Ei "paypal|sync|webhook|error|exception"
|
||||
cd /var/www/html
|
||||
php artisan migrate
|
||||
php artisan dewemoji:import-live-sql /var/www/html/dewemojiAPI_DB.sql --truncate
|
||||
```
|
||||
|
||||
---
|
||||
Sanity check:
|
||||
|
||||
## 7) Extension Release Order
|
||||
```bash
|
||||
php artisan tinker --execute="echo DB::table('emojis')->count().PHP_EOL;"
|
||||
php artisan tinker --execute="echo DB::table('emoji_keywords')->count().PHP_EOL;"
|
||||
```
|
||||
|
||||
Release order:
|
||||
1. Site/backend live first.
|
||||
2. Verify API/auth on live domain.
|
||||
3. Update extension default API base to live.
|
||||
4. Publish extension update.
|
||||
Expected: emojis ~2131, emoji_keywords ~13420.
|
||||
|
||||
This avoids extension users hitting endpoints that are not ready.
|
||||
## 7) MySQL GUI access via SSH tunnel
|
||||
|
||||
---
|
||||
For internal-only Coolify MySQL, tunnel to container IP.
|
||||
|
||||
## 8) Rollback Strategy
|
||||
Resolve MySQL container IP:
|
||||
|
||||
If release is broken:
|
||||
1. Re-deploy previous known-good git commit.
|
||||
```bash
|
||||
MYSQL_IP=$(ssh SERVER_USER@SERVER_HOST "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \$(docker ps --format '{{.ID}} {{.Names}}' | awk '/mysql|mariadb/{print \$1; exit}')")
|
||||
echo "$MYSQL_IP"
|
||||
```
|
||||
|
||||
Create tunnel:
|
||||
|
||||
```bash
|
||||
ssh -N -L 3307:${MYSQL_IP}:3306 SERVER_USER@SERVER_HOST
|
||||
```
|
||||
|
||||
Then connect in Sequel Ace/TablePlus using:
|
||||
|
||||
- Host `127.0.0.1`
|
||||
- Port `3307`
|
||||
- standard DB credentials
|
||||
|
||||
## 8) Local provider parity test (optional)
|
||||
|
||||
Switch local to live provider mode and verify with real keys.
|
||||
|
||||
```bash
|
||||
BASE=http://127.0.0.1:8000/v1
|
||||
curl -X POST "$BASE/license/verify" -H "Content-Type: application/json" -d '{"key":"<real_key>"}'
|
||||
```
|
||||
|
||||
Also test activate/deactivate cycle, then return local env to sandbox mode.
|
||||
|
||||
## 9) APK release dependency note
|
||||
|
||||
App updater URLs used by the site:
|
||||
|
||||
- `https://dewemoji.com/downloads/version.json`
|
||||
- `https://dewemoji.com/downloads/dewemoji-latest.apk`
|
||||
|
||||
For detailed APK build/release flow, use `dewemoji-apk-companion-build-walkthrough.md`.
|
||||
|
||||
## 10) Rollback
|
||||
|
||||
1. Re-deploy previous known-good commit.
|
||||
2. Run:
|
||||
|
||||
```bash
|
||||
@@ -197,5 +203,4 @@ php artisan config:cache
|
||||
php artisan queue:restart
|
||||
```
|
||||
|
||||
3. If issue is emoji dataset, use snapshot activation in admin catalog.
|
||||
|
||||
3. If issue is dataset-specific, switch to a known-good snapshot via admin tooling.
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# Dewemoji Admin Dashboard Walkthrough
|
||||
|
||||
This guide explains how to access and operate the admin dashboard, plus how each section is wired in the current build.
|
||||
|
||||
## Access
|
||||
|
||||
1. Sign in with an admin user (role `admin`).
|
||||
2. Navigate to `/dashboard/admin/*` routes.
|
||||
3. The sidebar automatically shows admin navigation when the session user is admin.
|
||||
|
||||
## Admin Sections
|
||||
|
||||
### Analytics
|
||||
|
||||
Route: `/dashboard/admin/analytics`
|
||||
What it shows:
|
||||
- User totals, active subscriptions, webhook counts
|
||||
- Recent webhook events
|
||||
|
||||
Data source:
|
||||
- Direct database counts via `AdminDashboardController::analytics()`
|
||||
|
||||
### Users
|
||||
|
||||
Route: `/dashboard/admin/users`
|
||||
What it shows:
|
||||
- User list (latest 50)
|
||||
- Filters: search, tier, role
|
||||
- Inline tier update (free/personal)
|
||||
|
||||
Actions:
|
||||
- Update tier: POST `/dashboard/admin/users/tier`
|
||||
|
||||
### Subscriptions
|
||||
|
||||
Route: `/dashboard/admin/subscriptions`
|
||||
What it shows:
|
||||
- Subscription list (latest 50)
|
||||
- Grant/revoke forms
|
||||
|
||||
Actions:
|
||||
- Grant: POST `/dashboard/admin/subscriptions/grant`
|
||||
- Revoke: POST `/dashboard/admin/subscriptions/revoke`
|
||||
|
||||
Notes:
|
||||
- Granting an active subscription automatically sets user tier to `personal`.
|
||||
- Revoking updates the user tier based on remaining active subscriptions.
|
||||
|
||||
### Pricing
|
||||
|
||||
Route: `/dashboard/admin/pricing`
|
||||
What it shows:
|
||||
- Pricing plans with editable fields
|
||||
- Change log (latest 5)
|
||||
|
||||
Actions:
|
||||
- Update pricing: POST `/dashboard/admin/pricing/update`
|
||||
- Reset to defaults: POST `/dashboard/admin/pricing/reset`
|
||||
|
||||
Notes:
|
||||
- Updates are stored in `pricing_plans`.
|
||||
- Each change logs a snapshot to `pricing_changes`.
|
||||
|
||||
### Webhooks
|
||||
|
||||
Route: `/dashboard/admin/webhooks`
|
||||
What it shows:
|
||||
- Recent webhook events (latest 50)
|
||||
- Replay action per event
|
||||
|
||||
Actions:
|
||||
- Replay: POST `/dashboard/admin/webhooks/{id}/replay`
|
||||
|
||||
Notes:
|
||||
- Replay marks the event as `pending`. (Actual processing should be handled by the webhook processor job/worker.)
|
||||
|
||||
### Settings
|
||||
|
||||
Route: `/dashboard/admin/settings`
|
||||
What it shows:
|
||||
- Current environment config summaries
|
||||
- Editable settings for public access + maintenance mode
|
||||
|
||||
Actions:
|
||||
- Update settings: POST `/dashboard/admin/settings/update`
|
||||
|
||||
Settings stored:
|
||||
- `maintenance_enabled` (bool)
|
||||
- `public_enforce` (bool)
|
||||
- `public_origins` (array)
|
||||
- `public_extension_ids` (array)
|
||||
- `public_hourly_limit` (int)
|
||||
|
||||
## Admin Routes Reference
|
||||
|
||||
- GET `/dashboard/admin/analytics`
|
||||
- GET `/dashboard/admin/users`
|
||||
- POST `/dashboard/admin/users/tier`
|
||||
- GET `/dashboard/admin/subscriptions`
|
||||
- POST `/dashboard/admin/subscriptions/grant`
|
||||
- POST `/dashboard/admin/subscriptions/revoke`
|
||||
- GET `/dashboard/admin/pricing`
|
||||
- POST `/dashboard/admin/pricing/update`
|
||||
- POST `/dashboard/admin/pricing/reset`
|
||||
- GET `/dashboard/admin/webhooks`
|
||||
- POST `/dashboard/admin/webhooks/{id}/replay`
|
||||
- GET `/dashboard/admin/settings`
|
||||
- POST `/dashboard/admin/settings/update`
|
||||
|
||||
## Notes for Future Wiring
|
||||
|
||||
- Replace placeholder analytics charts with real metrics/graphs.
|
||||
- Add pagination + sorting for users, subscriptions, and webhooks.
|
||||
- Add confirmation dialogs for destructive actions.
|
||||
- Wire replay to actual processor/job queue if needed.
|
||||
171
dewemoji-apk-companion-build-walkthrough.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Dewemoji APK Companion Runbook
|
||||
|
||||
This is the single guide for APK build, release, direct distribution, and versioning.
|
||||
|
||||
## 1) Scope
|
||||
|
||||
The Android companion uses:
|
||||
|
||||
- local bundled UI (`dewemoji-capacitor/src` -> `dewemoji-capacitor/www`)
|
||||
- native floating bubble launcher (copy/search workflow)
|
||||
- direct APK update via hosted `version.json`
|
||||
|
||||
The bubble is not an IME and does not perform direct cross-app insertion.
|
||||
|
||||
## 2) Prerequisites
|
||||
|
||||
1. Node.js `20+`
|
||||
2. JDK `17`
|
||||
3. Android SDK / Android Studio
|
||||
4. `adb`
|
||||
5. optional signing toolchain (`apksigner`)
|
||||
|
||||
Install deps once:
|
||||
|
||||
```bash
|
||||
cd /Users/dwindown/Developments/dewemoji
|
||||
npm --prefix dewemoji-capacitor install
|
||||
```
|
||||
|
||||
## 3) Source and build model
|
||||
|
||||
Do not edit `dewemoji-capacitor/www/*` directly.
|
||||
|
||||
- edit source in `dewemoji-capacitor/src/*`
|
||||
- generate runtime assets via:
|
||||
|
||||
```bash
|
||||
npm --prefix dewemoji-capacitor run build
|
||||
```
|
||||
|
||||
## 4) Debug build and device install
|
||||
|
||||
```bash
|
||||
cd /Users/dwindown/Developments/dewemoji
|
||||
npm --prefix dewemoji-capacitor run build
|
||||
cd dewemoji-capacitor
|
||||
npx cap sync android
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## 5) Release build
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
./scripts/apk/build-release.sh
|
||||
```
|
||||
|
||||
Script behavior:
|
||||
|
||||
1. read `versionCode` and `versionName` from `android/app/build.gradle`
|
||||
2. rebuild web assets
|
||||
3. run `cap sync android`
|
||||
4. build release APK
|
||||
5. sign if signing env vars are present
|
||||
6. output artifact under `dewemoji-capacitor/dist/apk/`
|
||||
|
||||
Output naming:
|
||||
|
||||
- `dewemoji-v{versionName}-{versionCode}.apk`
|
||||
|
||||
## 6) Signing env (optional but recommended)
|
||||
|
||||
```bash
|
||||
export ANDROID_KEYSTORE_PATH="/absolute/path/release.jks"
|
||||
export ANDROID_KEYSTORE_PASSWORD="..."
|
||||
export ANDROID_KEY_ALIAS="..."
|
||||
export ANDROID_KEY_PASSWORD="..."
|
||||
```
|
||||
|
||||
## 7) Direct release to Cloudflare R2
|
||||
|
||||
Required env:
|
||||
|
||||
```bash
|
||||
export R2_ACCOUNT_ID="..."
|
||||
export R2_ACCESS_KEY_ID="..."
|
||||
export R2_SECRET_ACCESS_KEY="..."
|
||||
export R2_BUCKET="dewemoji-downloads"
|
||||
export R2_PUBLIC_BASE_URL="https://downloads.dewemoji.com"
|
||||
```
|
||||
|
||||
Canonical update URLs used by app updater:
|
||||
|
||||
- `https://dewemoji.com/downloads/version.json`
|
||||
- `https://dewemoji.com/downloads/dewemoji-latest.apk`
|
||||
|
||||
Publish:
|
||||
|
||||
```bash
|
||||
./scripts/apk/publish-r2.sh \
|
||||
--apk dewemoji-capacitor/dist/apk/dewemoji-v1.1.2-112.apk \
|
||||
--version-name 1.1.2 \
|
||||
--version-code 112 \
|
||||
--min-supported-version-code 100 \
|
||||
--notes "Bug fixes and update UX improvements" \
|
||||
--force false
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
./scripts/apk/verify-release.sh --base-url https://dewemoji.com/downloads
|
||||
```
|
||||
|
||||
## 8) Versioning rules
|
||||
|
||||
1. `versionCode` must increase on every release.
|
||||
2. `versionName` is user-facing label; ordering uses `versionCode`.
|
||||
3. upload APK first, then publish/update `version.json`.
|
||||
4. never compare update order using `versionName`.
|
||||
|
||||
Expected `version.json` fields:
|
||||
|
||||
- `versionName` (string)
|
||||
- `versionCode` (number)
|
||||
- `apkUrl` (string)
|
||||
- `notes` (optional)
|
||||
- `force` (optional bool)
|
||||
|
||||
## 9) Rollback
|
||||
|
||||
1. keep old versioned APK objects immutable
|
||||
2. repoint latest artifact (`dewemoji-latest.apk`) to known good version
|
||||
3. republish matching `version.json`
|
||||
4. rerun verify script
|
||||
|
||||
## 10) High-value troubleshooting
|
||||
|
||||
### `cap sync android` failures
|
||||
|
||||
```bash
|
||||
npm --prefix dewemoji-capacitor install
|
||||
cd dewemoji-capacitor
|
||||
npx cap sync android
|
||||
```
|
||||
|
||||
### Bubble not appearing
|
||||
|
||||
1. confirm overlay permission
|
||||
2. confirm notification permission (Android 13+)
|
||||
3. remove OEM battery restrictions
|
||||
|
||||
### APK still shows old UI
|
||||
|
||||
Rebuild assets, sync, and reinstall debug APK.
|
||||
|
||||
## 11) Key files
|
||||
|
||||
- `dewemoji-capacitor/src/index.html`
|
||||
- `dewemoji-capacitor/src/app.css`
|
||||
- `dewemoji-capacitor/src/app.js`
|
||||
- `dewemoji-capacitor/android/app/build.gradle`
|
||||
- `dewemoji-capacitor/android/app/src/main/java/com/dewemoji/app/MainActivity.java`
|
||||
- `dewemoji-capacitor/android/app/src/main/java/com/dewemoji/app/OverlayBubbleService.java`
|
||||
- `dewemoji-capacitor/android/app/src/main/java/com/dewemoji/app/plugins/DewemojiOverlayPlugin.java`
|
||||
- `scripts/apk/build-release.sh`
|
||||
- `scripts/apk/publish-r2.sh`
|
||||
- `scripts/apk/verify-release.sh`
|
||||
15
dewemoji-capacitor/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
|
||||
# Capacitor / Android generated files
|
||||
android/.gradle/
|
||||
android/.idea/
|
||||
android/local.properties
|
||||
android/app/build/
|
||||
android/build/
|
||||
dist/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
101
dewemoji-capacitor/android/.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
dewemoji-capacitor/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
59
dewemoji-capacitor/android/app/build.gradle
Normal file
@@ -0,0 +1,59 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace "com.dewemoji.app"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.dewemoji.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 4
|
||||
versionName "1.0.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk7'
|
||||
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
19
dewemoji-capacitor/android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
21
dewemoji-capacitor/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
54
dewemoji-capacitor/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".OverlayBubbleService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:stopWithTask="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Floating overlay bubble for quick emoji search and copy" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.dewemoji.app;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.webkit.WebView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
|
||||
import com.dewemoji.app.plugins.DewemojiOverlayPlugin;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
private static final String TAG = "DewemojiUpdater";
|
||||
private static final String VERSION_URL = "https://dewemoji.com/downloads/version.json";
|
||||
private static final String EXTRA_OPENED_FROM_BUBBLE = "dewemoji_opened_from_bubble";
|
||||
private static final int CONNECT_TIMEOUT_MS = 10000;
|
||||
private static final int READ_TIMEOUT_MS = 15000;
|
||||
private boolean openedFromBubble = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(DewemojiOverlayPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
openedFromBubble = wasOpenedFromBubble(getIntent());
|
||||
applySystemBarMode();
|
||||
getWindow().getDecorView().post(this::applySystemBarMode);
|
||||
checkForUpdates(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
openedFromBubble = wasOpenedFromBubble(intent);
|
||||
applySystemBarMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) {
|
||||
applySystemBarMode();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
applySystemBarMode();
|
||||
getWindow().getDecorView().post(this::applySystemBarMode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
WebView webView = getBridge() != null ? getBridge().getWebView() : null;
|
||||
if (webView == null) {
|
||||
super.onBackPressed();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
webView.evaluateJavascript(
|
||||
"(function(){try{return (window.dewemojiHandleAndroidBack && window.dewemojiHandleAndroidBack()) ? '1' : '0';}catch(e){return '0';}})();",
|
||||
value -> {
|
||||
boolean handled = value != null && value.contains("1");
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
return;
|
||||
}
|
||||
MainActivity.super.onBackPressed();
|
||||
}
|
||||
);
|
||||
} catch (Exception ex) {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
} else {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean wasOpenedFromBubble(Intent intent) {
|
||||
return intent != null && intent.getBooleanExtra(EXTRA_OPENED_FROM_BUBBLE, false);
|
||||
}
|
||||
|
||||
private void applySystemBarMode() {
|
||||
// Fallback to non-immersive mode whenever the overlay bubble service is active;
|
||||
// some launch paths may not preserve the intent extra on all OEMs.
|
||||
if (openedFromBubble || OverlayBubbleService.isRunning()) {
|
||||
showSystemBars();
|
||||
return;
|
||||
}
|
||||
hideSystemBars();
|
||||
}
|
||||
|
||||
private void hideSystemBars() {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowInsetsControllerCompat controller =
|
||||
new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView());
|
||||
controller.hide(WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.navigationBars());
|
||||
controller.setSystemBarsBehavior(
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
);
|
||||
}
|
||||
|
||||
private void showSystemBars() {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
|
||||
WindowInsetsControllerCompat controller =
|
||||
new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView());
|
||||
controller.show(WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.navigationBars());
|
||||
}
|
||||
|
||||
private void checkForUpdates(boolean manual) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
UpdateMetadata metadata = fetchVersionMetadata();
|
||||
if (metadata == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long installedVersion = getInstalledVersionCode();
|
||||
if (metadata.versionCode <= installedVersion) {
|
||||
if (manual) {
|
||||
runOnUiThread(() ->
|
||||
Toast.makeText(this, "Dewemoji is up to date", Toast.LENGTH_SHORT).show()
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
runOnUiThread(() -> showUpdateDialog(metadata));
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Update check failed", ex);
|
||||
if (manual) {
|
||||
runOnUiThread(() ->
|
||||
Toast.makeText(this, "Update check failed", Toast.LENGTH_SHORT).show()
|
||||
);
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private UpdateMetadata fetchVersionMetadata() throws Exception {
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(VERSION_URL).openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code < 200 || code >= 300) {
|
||||
throw new IllegalStateException("Unexpected status " + code);
|
||||
}
|
||||
|
||||
try (InputStream in = new BufferedInputStream(conn.getInputStream());
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
String json = out.toString(StandardCharsets.UTF_8.name());
|
||||
JSONObject obj = new JSONObject(json);
|
||||
return UpdateMetadata.fromJson(obj);
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private long getInstalledVersionCode() throws Exception {
|
||||
PackageManager packageManager = getPackageManager();
|
||||
PackageInfo info = packageManager.getPackageInfo(getPackageName(), 0);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
return info.getLongVersionCode();
|
||||
}
|
||||
return info.versionCode;
|
||||
}
|
||||
|
||||
private void showUpdateDialog(UpdateMetadata metadata) {
|
||||
StringBuilder message = new StringBuilder();
|
||||
message.append("New version ").append(metadata.versionName).append(" is available.");
|
||||
if (!metadata.notes.isEmpty()) {
|
||||
message.append("\n\n").append(metadata.notes);
|
||||
}
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(this)
|
||||
.setTitle("Update Dewemoji")
|
||||
.setMessage(message.toString())
|
||||
.setPositiveButton("Download", (dialog, which) -> openApkDownloadPage(metadata.apkUrl))
|
||||
.setCancelable(!metadata.force);
|
||||
|
||||
if (!metadata.force) {
|
||||
builder.setNegativeButton("Later", null);
|
||||
}
|
||||
|
||||
builder.show();
|
||||
}
|
||||
|
||||
private void openApkDownloadPage(String apkUrl) {
|
||||
try {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkUrl));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
} catch (ActivityNotFoundException ex) {
|
||||
Toast.makeText(this, "No browser found", Toast.LENGTH_SHORT).show();
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "Failed to open APK URL", ex);
|
||||
Toast.makeText(this, "Cannot open update URL", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private static class UpdateMetadata {
|
||||
final String versionName;
|
||||
final long versionCode;
|
||||
final String apkUrl;
|
||||
final String notes;
|
||||
final boolean force;
|
||||
|
||||
private UpdateMetadata(
|
||||
String versionName,
|
||||
long versionCode,
|
||||
String apkUrl,
|
||||
String notes,
|
||||
boolean force
|
||||
) {
|
||||
this.versionName = versionName;
|
||||
this.versionCode = versionCode;
|
||||
this.apkUrl = apkUrl;
|
||||
this.notes = notes;
|
||||
this.force = force;
|
||||
}
|
||||
|
||||
static UpdateMetadata fromJson(JSONObject obj) {
|
||||
String versionName = obj.optString("versionName", "");
|
||||
long versionCode = obj.optLong("versionCode", 0);
|
||||
String apkUrl = obj.optString("apkUrl", "");
|
||||
String notes = obj.optString("notes", "");
|
||||
boolean force = obj.optBoolean("force", false);
|
||||
|
||||
if (versionName.isEmpty() || versionCode <= 0 || apkUrl.isEmpty()) {
|
||||
throw new IllegalStateException("Invalid version metadata payload");
|
||||
}
|
||||
|
||||
return new UpdateMetadata(versionName, versionCode, apkUrl, notes, force);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
package com.dewemoji.app;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
public class OverlayBubbleService extends Service {
|
||||
private static final String TAG = "DewemojiBubbleService";
|
||||
public static final String ACTION_START = "com.dewemoji.app.action.BUBBLE_START";
|
||||
public static final String ACTION_STOP = "com.dewemoji.app.action.BUBBLE_STOP";
|
||||
public static final String ACTION_OPEN = "com.dewemoji.app.action.BUBBLE_OPEN";
|
||||
|
||||
public static final String PREFS_NAME = "dewemoji_native_state";
|
||||
public static final String PREF_BUBBLE_X = "bubblePositionX";
|
||||
public static final String PREF_BUBBLE_Y = "bubblePositionY";
|
||||
public static final String NOTIFICATION_CHANNEL_ID = "dewemoji_bubble";
|
||||
|
||||
private static final int NOTIFICATION_ID = 41001;
|
||||
private static volatile boolean running = false;
|
||||
|
||||
private WindowManager windowManager;
|
||||
private ImageView bubbleView;
|
||||
private View panelBackdropView;
|
||||
private View panelView;
|
||||
private WebView panelWebView;
|
||||
private WindowManager.LayoutParams bubbleParams;
|
||||
private WindowManager.LayoutParams panelBackdropParams;
|
||||
private WindowManager.LayoutParams panelParams;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
public static boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
ensureNotificationChannel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
final String action = intent != null ? intent.getAction() : null;
|
||||
|
||||
if (ACTION_STOP.equals(action)) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (ACTION_OPEN.equals(action)) {
|
||||
openMainActivity();
|
||||
if (running) {
|
||||
startForegroundCompat();
|
||||
return START_STICKY;
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDrawOverlays()) {
|
||||
Toast.makeText(this, "Overlay permission is required for Dewemoji bubble", Toast.LENGTH_SHORT).show();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
startForegroundCompat();
|
||||
ensureBubbleView();
|
||||
// `isAttachedToWindow()` can be false immediately after addView on some
|
||||
// devices/OEM builds even though the overlay is successfully added.
|
||||
running = bubbleView != null;
|
||||
if (!running) {
|
||||
Toast.makeText(this, "Could not show Dewemoji bubble", Toast.LENGTH_SHORT).show();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
return START_STICKY;
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "Failed to start bubble service", ex);
|
||||
Toast.makeText(this, "Failed to start Dewemoji bubble", Toast.LENGTH_SHORT).show();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
running = false;
|
||||
removePanelView();
|
||||
removeBubbleView();
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean canDrawOverlays() {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(this);
|
||||
}
|
||||
|
||||
private void ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationChannel channel = manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
channel = new NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"Dewemoji Bubble",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
channel.setDescription("Quick access bubble for search and copy");
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private Notification buildNotification() {
|
||||
PendingIntent openPendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
1001,
|
||||
buildMainActivityIntent(),
|
||||
pendingIntentFlags(true)
|
||||
);
|
||||
|
||||
Intent stopIntent = new Intent(this, OverlayBubbleService.class);
|
||||
stopIntent.setAction(ACTION_STOP);
|
||||
PendingIntent stopPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
1002,
|
||||
stopIntent,
|
||||
pendingIntentFlags(false)
|
||||
);
|
||||
|
||||
return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("Dewemoji bubble is running")
|
||||
.setContentText("Tap bubble to open Dewemoji, copy emoji, then paste in your other app")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentIntent(openPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(android.R.drawable.ic_menu_view, "Open Dewemoji", openPendingIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop Bubble", stopPendingIntent)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void startForegroundCompat() {
|
||||
Notification notification = buildNotification();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
);
|
||||
return;
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification);
|
||||
}
|
||||
|
||||
private int pendingIntentFlags(boolean updateCurrent) {
|
||||
int flags = updateCurrent ? PendingIntent.FLAG_UPDATE_CURRENT : 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
flags |= PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
private Intent buildMainActivityIntent() {
|
||||
Intent launch = new Intent(this, MainActivity.class);
|
||||
launch.setAction(Intent.ACTION_MAIN);
|
||||
launch.addCategory(Intent.CATEGORY_LAUNCHER);
|
||||
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
launch.putExtra("dewemoji_opened_from_bubble", true);
|
||||
return launch;
|
||||
}
|
||||
|
||||
private void openMainActivity() {
|
||||
try {
|
||||
hidePanelView();
|
||||
startActivity(buildMainActivityIntent());
|
||||
} catch (Exception ex) {
|
||||
Toast.makeText(this, "Could not open Dewemoji", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureBubbleView() {
|
||||
if (bubbleView != null && bubbleView.isAttachedToWindow()) {
|
||||
return;
|
||||
}
|
||||
if (windowManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
bubbleView = new ImageView(this);
|
||||
bubbleView.setImageResource(R.drawable.dewemoji_bubble_mark);
|
||||
bubbleView.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
int padding = dp(3);
|
||||
bubbleView.setPadding(padding, padding, padding, padding);
|
||||
|
||||
GradientDrawable background = new GradientDrawable();
|
||||
background.setShape(GradientDrawable.OVAL);
|
||||
background.setColor(0xFFFFFFFF);
|
||||
background.setStroke(dp(1), 0x22000000);
|
||||
bubbleView.setBackground(background);
|
||||
bubbleView.setElevation(dp(6));
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
bubbleView.setClipToOutline(true);
|
||||
}
|
||||
|
||||
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
: WindowManager.LayoutParams.TYPE_PHONE;
|
||||
|
||||
bubbleParams = new WindowManager.LayoutParams(
|
||||
dp(64),
|
||||
dp(64),
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||
PixelFormat.TRANSLUCENT
|
||||
);
|
||||
bubbleParams.gravity = Gravity.TOP | Gravity.START;
|
||||
bubbleParams.x = prefs.getInt(PREF_BUBBLE_X, dp(10));
|
||||
bubbleParams.y = prefs.getInt(PREF_BUBBLE_Y, dp(180));
|
||||
clampBubblePosition();
|
||||
|
||||
bubbleView.setOnTouchListener(new View.OnTouchListener() {
|
||||
private int startX;
|
||||
private int startY;
|
||||
private float downRawX;
|
||||
private float downRawY;
|
||||
private long downTime;
|
||||
private boolean moved;
|
||||
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
if (bubbleParams == null || windowManager == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
startX = bubbleParams.x;
|
||||
startY = bubbleParams.y;
|
||||
downRawX = event.getRawX();
|
||||
downRawY = event.getRawY();
|
||||
downTime = System.currentTimeMillis();
|
||||
moved = false;
|
||||
return true;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
int dx = Math.round(event.getRawX() - downRawX);
|
||||
int dy = Math.round(event.getRawY() - downRawY);
|
||||
if (Math.abs(dx) > dp(3) || Math.abs(dy) > dp(3)) {
|
||||
moved = true;
|
||||
}
|
||||
bubbleParams.x = startX + dx;
|
||||
bubbleParams.y = startY + dy;
|
||||
clampBubblePosition();
|
||||
try {
|
||||
windowManager.updateViewLayout(bubbleView, bubbleParams);
|
||||
} catch (Exception ignored) {}
|
||||
return true;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
persistBubblePosition();
|
||||
long pressDuration = System.currentTimeMillis() - downTime;
|
||||
if (!moved && pressDuration < 325) {
|
||||
togglePanelView();
|
||||
} else if (moved) {
|
||||
positionPanelNearBubble(false);
|
||||
try {
|
||||
if (panelView != null && panelView.isAttachedToWindow()) {
|
||||
windowManager.updateViewLayout(panelView, panelParams);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
windowManager.addView(bubbleView, bubbleParams);
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "windowManager.addView failed", ex);
|
||||
String reason = ex.getClass().getSimpleName();
|
||||
String message = ex.getMessage();
|
||||
if (message != null && !message.trim().isEmpty()) {
|
||||
String oneLine = message.replace('\n', ' ').trim();
|
||||
if (oneLine.length() > 90) oneLine = oneLine.substring(0, 90) + "…";
|
||||
reason = reason + ": " + oneLine;
|
||||
}
|
||||
Toast.makeText(this, reason, Toast.LENGTH_LONG).show();
|
||||
bubbleView = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void persistBubblePosition() {
|
||||
if (bubbleParams == null || prefs == null) {
|
||||
return;
|
||||
}
|
||||
prefs.edit()
|
||||
.putInt(PREF_BUBBLE_X, bubbleParams.x)
|
||||
.putInt(PREF_BUBBLE_Y, bubbleParams.y)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void togglePanelView() {
|
||||
if (panelView != null && panelView.isAttachedToWindow()) {
|
||||
hidePanelView();
|
||||
return;
|
||||
}
|
||||
showPanelView();
|
||||
}
|
||||
|
||||
private void showPanelView() {
|
||||
if (windowManager == null) return;
|
||||
ensurePanelBackdropView();
|
||||
ensurePanelView();
|
||||
if (panelView == null || panelParams == null) return;
|
||||
|
||||
showPanelBackdrop();
|
||||
positionPanelNearBubble(true);
|
||||
try {
|
||||
if (panelView.isAttachedToWindow()) {
|
||||
windowManager.updateViewLayout(panelView, panelParams);
|
||||
} else {
|
||||
windowManager.addView(panelView, panelParams);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "windowManager.addView(panel) failed", ex);
|
||||
Toast.makeText(this, "Failed to show panel", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void hidePanelView() {
|
||||
if (windowManager != null && panelView != null) {
|
||||
try {
|
||||
if (panelView.isAttachedToWindow()) {
|
||||
windowManager.removeView(panelView);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
hidePanelBackdrop();
|
||||
}
|
||||
|
||||
private void removePanelView() {
|
||||
hidePanelView();
|
||||
if (panelWebView != null) {
|
||||
try {
|
||||
panelWebView.stopLoading();
|
||||
panelWebView.loadUrl("about:blank");
|
||||
panelWebView.destroy();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
panelWebView = null;
|
||||
panelView = null;
|
||||
panelParams = null;
|
||||
panelBackdropView = null;
|
||||
panelBackdropParams = null;
|
||||
}
|
||||
|
||||
private void ensurePanelBackdropView() {
|
||||
if (panelBackdropView != null && panelBackdropParams != null) return;
|
||||
if (windowManager == null) return;
|
||||
|
||||
View backdrop = new View(this);
|
||||
backdrop.setBackgroundColor(0x26000000);
|
||||
backdrop.setClickable(true);
|
||||
backdrop.setFocusable(false);
|
||||
backdrop.setOnTouchListener((v, event) -> {
|
||||
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
|
||||
hidePanelView();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
panelBackdropView = backdrop;
|
||||
|
||||
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
: WindowManager.LayoutParams.TYPE_PHONE;
|
||||
|
||||
panelBackdropParams = new WindowManager.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||
PixelFormat.TRANSLUCENT
|
||||
);
|
||||
panelBackdropParams.gravity = Gravity.TOP | Gravity.START;
|
||||
panelBackdropParams.x = 0;
|
||||
panelBackdropParams.y = 0;
|
||||
}
|
||||
|
||||
private void showPanelBackdrop() {
|
||||
if (windowManager == null || panelBackdropView == null || panelBackdropParams == null) return;
|
||||
try {
|
||||
if (panelBackdropView.isAttachedToWindow()) {
|
||||
windowManager.updateViewLayout(panelBackdropView, panelBackdropParams);
|
||||
} else {
|
||||
windowManager.addView(panelBackdropView, panelBackdropParams);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Failed to show panel backdrop", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void hidePanelBackdrop() {
|
||||
if (windowManager == null || panelBackdropView == null) return;
|
||||
try {
|
||||
if (panelBackdropView.isAttachedToWindow()) {
|
||||
windowManager.removeView(panelBackdropView);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensurePanelView() {
|
||||
if (panelView != null && panelParams != null) return;
|
||||
if (windowManager == null) return;
|
||||
|
||||
FrameLayout card = new FrameLayout(this);
|
||||
card.setClickable(true);
|
||||
card.setFocusable(true);
|
||||
card.setFocusableInTouchMode(true);
|
||||
card.setElevation(dp(18));
|
||||
|
||||
GradientDrawable bg = new GradientDrawable();
|
||||
bg.setColor(0xFFFFFFFF);
|
||||
bg.setCornerRadius(dp(18));
|
||||
bg.setStroke(dp(1), 0x22000000);
|
||||
card.setBackground(bg);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
card.setClipToOutline(true);
|
||||
}
|
||||
|
||||
WebView webView = new WebView(this);
|
||||
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
webView.setVerticalScrollBarEnabled(false);
|
||||
webView.setHorizontalScrollBarEnabled(false);
|
||||
webView.setBackgroundColor(0xFFFFFFFF);
|
||||
webView.setWebChromeClient(new WebChromeClient());
|
||||
webView.setWebViewClient(new WebViewClient());
|
||||
webView.addJavascriptInterface(new OverlayWebBridge(), "DewemojiOverlayHost");
|
||||
|
||||
WebSettings ws = webView.getSettings();
|
||||
ws.setJavaScriptEnabled(true);
|
||||
ws.setDomStorageEnabled(true);
|
||||
ws.setAllowFileAccess(true);
|
||||
ws.setAllowContentAccess(true);
|
||||
ws.setMediaPlaybackRequiresUserGesture(false);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
|
||||
ws.setAllowFileAccessFromFileURLs(true);
|
||||
ws.setAllowUniversalAccessFromFileURLs(true);
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
ws.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
|
||||
}
|
||||
|
||||
FrameLayout.LayoutParams webLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
);
|
||||
card.addView(webView, webLp);
|
||||
panelWebView = webView;
|
||||
|
||||
try {
|
||||
webView.loadUrl(buildOverlayWebViewUrl());
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Failed to load overlay webview URL", ex);
|
||||
}
|
||||
|
||||
panelView = card;
|
||||
|
||||
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
: WindowManager.LayoutParams.TYPE_PHONE;
|
||||
panelParams = new WindowManager.LayoutParams(
|
||||
preferredPanelWidthPx(),
|
||||
maxPanelHeightPx(),
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
|
||||
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT
|
||||
);
|
||||
panelParams.gravity = Gravity.TOP | Gravity.START;
|
||||
panelParams.x = dp(12);
|
||||
panelParams.y = dp(120);
|
||||
panelParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
|
||||
}
|
||||
|
||||
private void positionPanelNearBubble(boolean resetIfOffscreen) {
|
||||
if (panelParams == null) return;
|
||||
int screenW = getResources().getDisplayMetrics().widthPixels;
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int availableH = Math.max(dp(280), screenH - topSafe - bottomSafe);
|
||||
panelParams.width = preferredPanelWidthPx();
|
||||
panelParams.height = Math.min(panelParams.height > 0 ? panelParams.height : maxPanelHeightPx(), maxPanelHeightPx());
|
||||
panelParams.x = Math.max(safeSideMarginPx(), (screenW - panelParams.width) / 2);
|
||||
panelParams.y = topSafe + Math.max(0, (availableH - panelParams.height) / 2);
|
||||
clampPanelPosition();
|
||||
}
|
||||
|
||||
private void clampPanelPosition() {
|
||||
if (panelParams == null) return;
|
||||
int screenW = getResources().getDisplayMetrics().widthPixels;
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int sideMargin = safeSideMarginPx();
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int panelW = panelParams.width > 0 ? panelParams.width : preferredPanelWidthPx();
|
||||
int panelH = panelParams.height > 0 ? panelParams.height : maxPanelHeightPx();
|
||||
|
||||
int maxX = Math.max(sideMargin, screenW - panelW - sideMargin);
|
||||
int maxY = Math.max(topSafe, screenH - panelH - bottomSafe);
|
||||
panelParams.x = Math.max(sideMargin, Math.min(panelParams.x, maxX));
|
||||
panelParams.y = Math.max(topSafe, Math.min(panelParams.y, maxY));
|
||||
}
|
||||
|
||||
private void clampBubblePosition() {
|
||||
if (bubbleParams == null) return;
|
||||
int screenW = getResources().getDisplayMetrics().widthPixels;
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int sideMargin = safeSideMarginPx();
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int bubbleW = bubbleParams.width > 0 ? bubbleParams.width : dp(64);
|
||||
int bubbleH = bubbleParams.height > 0 ? bubbleParams.height : dp(64);
|
||||
|
||||
int maxX = Math.max(sideMargin, screenW - bubbleW - sideMargin);
|
||||
int maxY = Math.max(topSafe, screenH - bubbleH - bottomSafe);
|
||||
bubbleParams.x = Math.max(sideMargin, Math.min(bubbleParams.x, maxX));
|
||||
bubbleParams.y = Math.max(topSafe, Math.min(bubbleParams.y, maxY));
|
||||
}
|
||||
|
||||
private int preferredPanelWidthPx() {
|
||||
int screenW = getResources().getDisplayMetrics().widthPixels;
|
||||
int minMargin = dp(10);
|
||||
int maxWidth = Math.max(dp(220), screenW - (minMargin * 2));
|
||||
int target = Math.round(screenW * 0.94f);
|
||||
int minWidth = Math.min(dp(280), maxWidth);
|
||||
return Math.max(minWidth, Math.min(maxWidth, target));
|
||||
}
|
||||
|
||||
private int maxPanelHeightPx() {
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int available = Math.max(dp(360), screenH - topSafe - bottomSafe);
|
||||
int maxHeight = Math.max(dp(260), available - dp(8));
|
||||
int target = Math.round(available * 0.92f);
|
||||
int minHeight = Math.min(dp(360), maxHeight);
|
||||
return Math.max(minHeight, Math.min(maxHeight, target));
|
||||
}
|
||||
|
||||
private int minPanelHeightPx() {
|
||||
int max = maxPanelHeightPx();
|
||||
return Math.min(max, dp(260));
|
||||
}
|
||||
|
||||
private int safeSideMarginPx() {
|
||||
return dp(10);
|
||||
}
|
||||
|
||||
private int safeTopInsetPx() {
|
||||
return readSystemDimenPx("status_bar_height") + dp(8);
|
||||
}
|
||||
|
||||
private int safeBottomInsetPx() {
|
||||
return readSystemDimenPx("navigation_bar_height") + dp(8);
|
||||
}
|
||||
|
||||
private int readSystemDimenPx(String name) {
|
||||
try {
|
||||
int resId = getResources().getIdentifier(name, "dimen", "android");
|
||||
if (resId > 0) {
|
||||
return getResources().getDimensionPixelSize(resId);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private String buildOverlayWebViewUrl() {
|
||||
return "file:///android_asset/public/index.html?mode=overlay";
|
||||
}
|
||||
|
||||
private boolean copyTextToClipboardNative(String text) {
|
||||
try {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard == null) return false;
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("dewemoji", String.valueOf(text == null ? "" : text)));
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Native clipboard copy failed", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private final class OverlayWebBridge {
|
||||
@JavascriptInterface
|
||||
public boolean copyText(String text) {
|
||||
return copyTextToClipboardNative(text);
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void setContentHeight(int cssPx) {
|
||||
if (cssPx <= 0) return;
|
||||
View target = panelView;
|
||||
if (target == null) return;
|
||||
target.post(() -> applyPanelContentHeightCss(cssPx));
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void closePanel() {
|
||||
hidePanelView();
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void openFullApp() {
|
||||
openMainActivity();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyPanelContentHeightCss(int cssPx) {
|
||||
if (panelParams == null || panelView == null || windowManager == null) return;
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
int desiredPx = Math.round(cssPx * density);
|
||||
desiredPx += dp(2);
|
||||
desiredPx = Math.max(minPanelHeightPx(), Math.min(desiredPx, maxPanelHeightPx()));
|
||||
if (Math.abs(desiredPx - panelParams.height) < dp(6)) {
|
||||
return;
|
||||
}
|
||||
panelParams.height = desiredPx;
|
||||
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int availableH = Math.max(dp(280), screenH - topSafe - bottomSafe);
|
||||
panelParams.y = topSafe + Math.max(0, (availableH - panelParams.height) / 2);
|
||||
clampPanelPosition();
|
||||
|
||||
try {
|
||||
if (panelView.isAttachedToWindow()) {
|
||||
windowManager.updateViewLayout(panelView, panelParams);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Failed to apply panel content height", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeBubbleView() {
|
||||
if (windowManager == null || bubbleView == null) {
|
||||
bubbleView = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
windowManager.removeView(bubbleView);
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
bubbleView = null;
|
||||
}
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
return Math.round(value * density);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.dewemoji.app.plugins;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.dewemoji.app.OverlayBubbleService;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "DewemojiOverlay")
|
||||
public class DewemojiOverlayPlugin extends Plugin {
|
||||
private static final String APP_STATE_KEY = "app_state_json";
|
||||
|
||||
@PluginMethod
|
||||
public void isOverlayPermissionGranted(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("granted", canDrawOverlays());
|
||||
call.resolve(out);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openOverlayPermissionSettings(PluginCall call) {
|
||||
try {
|
||||
Intent intent = new Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:" + getContext().getPackageName())
|
||||
);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
getContext().startActivity(intent);
|
||||
call.resolve();
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to open overlay settings", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void areNotificationsEnabled(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("enabled", notificationsEnabled());
|
||||
call.resolve(out);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openNotificationSettings(PluginCall call) {
|
||||
try {
|
||||
Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
|
||||
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName());
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
getContext().startActivity(intent);
|
||||
call.resolve();
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to open notification settings", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void isBubbleRunning(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("running", OverlayBubbleService.isRunning());
|
||||
call.resolve(out);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void startBubble(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
|
||||
if (!canDrawOverlays()) {
|
||||
out.put("started", false);
|
||||
out.put("reason", "overlay_permission_required");
|
||||
call.resolve(out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!notificationsEnabled()) {
|
||||
out.put("started", false);
|
||||
out.put("reason", "notifications_required");
|
||||
call.resolve(out);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Intent intent = new Intent(getContext(), OverlayBubbleService.class);
|
||||
intent.setAction(OverlayBubbleService.ACTION_START);
|
||||
ContextCompat.startForegroundService(getContext(), intent);
|
||||
out.put("started", true);
|
||||
call.resolve(out);
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to start Dewemoji bubble", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void stopBubble(PluginCall call) {
|
||||
try {
|
||||
Intent intent = new Intent(getContext(), OverlayBubbleService.class);
|
||||
intent.setAction(OverlayBubbleService.ACTION_STOP);
|
||||
getContext().startService(intent);
|
||||
JSObject out = new JSObject();
|
||||
out.put("stopped", true);
|
||||
call.resolve(out);
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to stop Dewemoji bubble", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getAppState(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
try {
|
||||
SharedPreferences prefs = nativePrefs();
|
||||
String raw = prefs.getString(APP_STATE_KEY, "{}");
|
||||
JSObject state = (raw == null || raw.trim().isEmpty()) ? new JSObject() : new JSObject(raw);
|
||||
out.put("state", state);
|
||||
call.resolve(out);
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to read app state", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void setAppState(PluginCall call) {
|
||||
try {
|
||||
JSObject incoming = call.getObject("state", new JSObject());
|
||||
if (incoming == null) {
|
||||
incoming = new JSObject();
|
||||
}
|
||||
nativePrefs().edit().putString(APP_STATE_KEY, incoming.toString()).apply();
|
||||
JSObject out = new JSObject();
|
||||
out.put("saved", true);
|
||||
call.resolve(out);
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to save app state", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private SharedPreferences nativePrefs() {
|
||||
return getContext().getSharedPreferences(OverlayBubbleService.PREFS_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private boolean canDrawOverlays() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return true;
|
||||
}
|
||||
return Settings.canDrawOverlays(getContext());
|
||||
}
|
||||
|
||||
private boolean notificationsEnabled() {
|
||||
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
getContext(),
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
BIN
dewemoji-capacitor/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Dewemoji</string>
|
||||
<string name="title_activity_main">Dewemoji</string>
|
||||
<string name="package_name">com.dewemoji.app</string>
|
||||
<string name="custom_url_scheme">com.dewemoji.app</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
29
dewemoji-capacitor/android/build.gradle
Normal file
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
3
dewemoji-capacitor/android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,3 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
22
dewemoji-capacitor/android/gradle.properties
Normal file
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
dewemoji-capacitor/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
dewemoji-capacitor/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
dewemoji-capacitor/android/gradlew
vendored
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
dewemoji-capacitor/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
dewemoji-capacitor/android/settings.gradle
Normal file
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
dewemoji-capacitor/android/variables.gradle
Normal file
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '13.0.0'
|
||||
}
|
||||
5
dewemoji-capacitor/capacitor.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appId": "com.dewemoji.app",
|
||||
"appName": "Dewemoji",
|
||||
"webDir": "www"
|
||||
}
|
||||
1075
dewemoji-capacitor/package-lock.json
generated
Normal file
18
dewemoji-capacitor/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "dewemoji-capacitor",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "node ./scripts/build-web.js",
|
||||
"test": "echo \"No tests configured\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.1.0",
|
||||
"@capacitor/cli": "^7.5.0",
|
||||
"@capacitor/core": "^8.1.0"
|
||||
}
|
||||
}
|
||||
26
dewemoji-capacitor/scripts/build-web.js
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const srcDir = path.join(rootDir, 'src');
|
||||
const outDir = path.join(rootDir, 'www');
|
||||
|
||||
function fail(message) {
|
||||
console.error(`error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
fail(`missing source directory: ${srcDir}`);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.cpSync(srcDir, outDir, { recursive: true });
|
||||
|
||||
console.log(`Built web assets: ${path.relative(rootDir, outDir)}`);
|
||||
} catch (error) {
|
||||
fail(error && error.message ? error.message : String(error));
|
||||
}
|
||||
967
dewemoji-capacitor/src/app.css
Normal file
@@ -0,0 +1,967 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb; --active: #60a5fa2b;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
|
||||
}
|
||||
/* explicit override by class */
|
||||
.theme-light {
|
||||
color-scheme: light;
|
||||
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
|
||||
}
|
||||
.theme-dark {
|
||||
color-scheme: dark;
|
||||
--bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151;
|
||||
}
|
||||
|
||||
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
|
||||
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
|
||||
|
||||
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); z-index: 9;}
|
||||
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
|
||||
.bar { display:flex; gap:6px; }
|
||||
.inp { flex:1; padding:8px 8px; border:1px solid var(--br); border-radius:8px; background:var(--bg); color:var(--fg); }
|
||||
/* buttons */
|
||||
.btn { padding:8px 10px; border:1px solid var(--br); border-radius:8px; background:var(--dim); cursor:pointer; }
|
||||
.btn:hover { filter: brightness(0.98); }
|
||||
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
|
||||
|
||||
/* disabled buttons (e.g., Load more) */
|
||||
.btn[disabled] { opacity:.55; cursor:not-allowed; }
|
||||
|
||||
.filters { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:8px 10px; border-bottom:1px solid var(--br); }
|
||||
.grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:8px; padding:10px; }
|
||||
|
||||
.card {
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:6px; padding:10px; border:1px solid var(--br); border-radius:12px; background:var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card .emo { font-size:28px; line-height:1; }
|
||||
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
.ft { display:flex; align-items:center; justify-content: space-between; gap:8px; padding:10px; border-top:1px solid var(--br);
|
||||
position:sticky; bottom:0; background:var(--bg); }
|
||||
.muted { color:var(--mut); }
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
|
||||
background: var(--dim); color: var(--fg); padding: 8px 12px; border-radius: 8px;
|
||||
border:1px solid var(--br); opacity: 0; pointer-events:none; transition: opacity .18s ease;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
.sel {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--br);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
appearance: none; /* cleaner look */
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--mut) 50%),
|
||||
linear-gradient(135deg, var(--mut) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% - 3px),
|
||||
calc(100% - 12px) calc(50% - 3px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.sel:disabled { opacity: .6; }
|
||||
.badge {
|
||||
padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
|
||||
background: var(--dim); color: var(--fg); border:1px solid var(--br);
|
||||
}
|
||||
|
||||
/* Backdrop + Sheet (modal) */
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.35);
|
||||
z-index: 80; opacity: 0; transition: opacity .18s ease;
|
||||
}
|
||||
.backdrop.show { opacity: 1; }
|
||||
|
||||
.sheet {
|
||||
position: fixed; right: 12px; bottom: 12px; left: 12px; max-width: 520px;
|
||||
margin: 0 auto; z-index: 81; background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--br); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
||||
transform: translateY(8px); opacity: 0; transition: transform .18s ease, opacity .18s ease;
|
||||
}
|
||||
.sheet.show { transform: translateY(0); opacity: 1; }
|
||||
.sheet-head { display:flex; align-items:center; justify-content:space-between; padding:12px; border-bottom:1px solid var(--br); border-radius: inherit}
|
||||
.sheet-body { padding:12px; display:grid; gap:14px; }
|
||||
.field .lbl { display:block; font-weight:600; margin-bottom:6px; }
|
||||
.field .hint { margin-top:6px; font-size:12px; }
|
||||
.row { display:flex; gap:8px; }
|
||||
|
||||
/* Radios */
|
||||
.radios { display:grid; gap:8px; }
|
||||
.radio { display:flex; align-items:center; gap:8px; padding:8px; border:1px solid var(--br); border-radius:8px; background: var(--bg); }
|
||||
.radio input[type="radio"] { accent-color: #3b82f6; }
|
||||
.radio[aria-disabled="true"] { opacity:.55; }
|
||||
|
||||
.tag {
|
||||
margin-left:auto; font-size:11px; padding:2px 8px; border-radius:9999px;
|
||||
border:1px solid var(--br); background: var(--dim); color: var(--fg);
|
||||
}
|
||||
|
||||
/* Badge (version) already exists — keep it; this is just a reminder */
|
||||
.badge { padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
|
||||
background: var(--dim); color: var(--fg); border:1px solid var(--br); }
|
||||
|
||||
/* Buttons & inputs already set; ensure icon buttons look good */
|
||||
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
|
||||
.btn[disabled] { opacity:.55; cursor:not-allowed; }
|
||||
|
||||
.diagbox {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--br);
|
||||
border-radius: 8px;
|
||||
background: var(--dim);
|
||||
color: var(--fg);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* apply to the glyph container you use */
|
||||
.emo, .emo * {
|
||||
font-family:
|
||||
"Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji",
|
||||
"Twemoji Mozilla", "EmojiOne Color", "Segoe UI Symbol",
|
||||
system-ui, sans-serif !important;
|
||||
font-variant-emoji: emoji;
|
||||
line-height: 1;
|
||||
}
|
||||
.emo img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#dewemoji-status {
|
||||
float: right;
|
||||
}
|
||||
/* Settings tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--br);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--dim);
|
||||
border: 1px solid var(--br);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab:not(.active) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
opacity: 1;
|
||||
}
|
||||
.tabpane { display: none; }
|
||||
.tabpane.active { display: block; }
|
||||
|
||||
/* Tone palette theming */
|
||||
.theme-light, :root.theme-light {
|
||||
--c-bg: var(--bg, #ffffff);
|
||||
--c-chip: var(--dim, #f3f4f6);
|
||||
--c-border: var(--br, #e5e7eb);
|
||||
}
|
||||
.theme-dark, :root.theme-dark {
|
||||
--c-bg: var(--bg, #0f172a);
|
||||
--c-chip: var(--dim, #111827);
|
||||
--c-border: var(--br, #374151);
|
||||
}
|
||||
|
||||
/* Accent color for focus/selection rings */
|
||||
.theme-light, :root.theme-light { --accent: #3b82f6; }
|
||||
.theme-dark, :root.theme-dark { --accent: #60a5fa; }
|
||||
|
||||
/* Tone palette buttons (popover + settings) */
|
||||
.tone-btn, .tone-chip {
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-chip);
|
||||
}
|
||||
.tone-btn.selected, .tone-chip.selected {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent) !important;
|
||||
}
|
||||
.tone-btn:focus-visible, .tone-chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ===== Settings sheet polish ===== */
|
||||
.sheet { max-width: 540px; }
|
||||
.sheet-head {
|
||||
position: sticky; top: 0; background: var(--bg);
|
||||
z-index: 2; border-bottom: 1px solid var(--br);
|
||||
}
|
||||
.sheet-head h3 { margin: 0; }
|
||||
|
||||
.sheet-body { padding-top: 10px; }
|
||||
.field { margin: 12px 0 16px; }
|
||||
.field .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
|
||||
|
||||
/* Compact rows */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto; /* input | Activate | Deactivate */
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.row + .row { margin-top: 6px; }
|
||||
|
||||
/* Inputs and small buttons */
|
||||
.inp { padding: 6px 10px; }
|
||||
.btn.sm { padding: 6px 10px; font-size: 12px; line-height: 1; }
|
||||
.btn.ghost { background: transparent; border: 1px solid var(--br); }
|
||||
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
|
||||
/* Tabs = segmented control */
|
||||
.tabs {
|
||||
background: var(--dim); padding: 4px; border-radius: 10px;
|
||||
display: inline-flex; gap: 4px; border: 1px solid var(--br);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tab { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-weight: 600; }
|
||||
.tab.active { background: var(--bg); box-shadow: inset 0 0 0 1px var(--br); }
|
||||
|
||||
/* License subtext wraps neatly */
|
||||
#license-status { display: inline-block; margin-left: 8px; }
|
||||
|
||||
/* Tone palette spacin.g + buttons */
|
||||
.tone-palette { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tone-chip {
|
||||
min-width: 40px; height: 36px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px; border: 1px solid var(--c-border); background: var(--c-chip);
|
||||
}
|
||||
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
||||
|
||||
/* Toast a tad higher so it doesn’t overlap sheet */
|
||||
.toast { bottom: 18px; }
|
||||
|
||||
/* Make the top controls a proper stacking context above the grid */
|
||||
.topbar, .filters-row {
|
||||
position: sticky; /* or relative if not sticky */
|
||||
z-index: 5;
|
||||
background: var(--c-bg, #0f172a); /* ensure it has a solid bg in dark & light */
|
||||
}
|
||||
|
||||
/* Or ensure the select dropdown sits above adjacent icons */
|
||||
select#sub {
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
div.card.active {
|
||||
background-color: var(--active)!important;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-column: 1 / -1;
|
||||
background-color: var(--active);
|
||||
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0px, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tone-option {
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
|
||||
background: var(--c-bg, #f3f4f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
/* ===== APK companion overrides (extension-like, denser) ===== */
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
padding-top: max(0px, env(safe-area-inset-top));
|
||||
padding-bottom: max(0px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.hdr {
|
||||
padding-top: calc(10px + env(safe-area-inset-top) * 0.25);
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
min-height: calc(100vh - 170px);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body.overlay-mode .grid {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
html.overlay-mode,
|
||||
body.overlay-mode {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
body.overlay-mode {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
body.overlay-mode .hdr,
|
||||
body.overlay-mode .ft {
|
||||
position: static;
|
||||
}
|
||||
|
||||
body.overlay-mode #theme {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.overlay-mode #settings-sheet,
|
||||
body.overlay-mode #sheet-backdrop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.overlay-mode {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body.overlay-mode .bar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body.overlay-mode .filters {
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
body.overlay-mode .inp,
|
||||
body.overlay-mode .sel {
|
||||
min-height: 48px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body.overlay-mode .btn {
|
||||
min-height: 46px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body.overlay-mode .btn.icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
body.overlay-mode .btn.w {
|
||||
min-width: 124px;
|
||||
}
|
||||
|
||||
body.overlay-mode .ft {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .sheet {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
max-width: none;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 44px rgba(0,0,0,.28);
|
||||
}
|
||||
|
||||
body.overlay-mode .sheet-head {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
body.overlay-mode .sheet-head h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
body.overlay-mode .sheet-body {
|
||||
padding: 14px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
body.overlay-mode .field {
|
||||
margin: 14px 0 18px;
|
||||
}
|
||||
|
||||
body.overlay-mode .field .lbl {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
body.overlay-mode .field .hint,
|
||||
body.overlay-mode .diagbox,
|
||||
body.overlay-mode .muted {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
body.overlay-mode .diagbox {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .row,
|
||||
body.overlay-mode .row-inline,
|
||||
body.overlay-mode .row-wrap {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .row-inline {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
body.overlay-mode .row-wrap .btn,
|
||||
body.overlay-mode .row-inline .btn {
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
body.overlay-mode #account-connect-form .row-block .inp,
|
||||
body.overlay-mode #account-connected .row-inline .btn {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tabs {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tab {
|
||||
min-height: 44px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body.overlay-mode .radio {
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-pref-group {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-swatch {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-circle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
body.overlay-mode .bubble-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .bubble-kv {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .bubble-kv > span:last-child {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-row {
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-option {
|
||||
min-height: 48px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse), (max-width: 900px) {
|
||||
body:not(.overlay-mode) .inp,
|
||||
body:not(.overlay-mode) .sel {
|
||||
min-height: 46px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .btn.icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .filters {
|
||||
gap: 8px;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .bar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .ft {
|
||||
gap: 10px;
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .sheet {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .sheet-head {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .sheet-head h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .sheet-body {
|
||||
padding: 14px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .field {
|
||||
margin: 14px 0 18px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .row,
|
||||
body:not(.overlay-mode) .row-inline,
|
||||
body:not(.overlay-mode) .row-wrap {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .row-inline {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .tabs {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .tab {
|
||||
min-height: 44px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .radio {
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) #account-connect-form .row-block .inp,
|
||||
body:not(.overlay-mode) #account-connected .row-inline .btn {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .bubble-kv {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 72px;
|
||||
padding: 8px 6px;
|
||||
gap: 4px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.card * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.card .emo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card .nm {
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card {
|
||||
min-height: 58px;
|
||||
gap: 2px;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card .nm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card .emo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.btn.w {
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.ft {
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom) * 0.5);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.row-inline {
|
||||
display: flex;
|
||||
grid-template-columns: none;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-wrap {
|
||||
display: flex;
|
||||
grid-template-columns: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row-block {
|
||||
display: block;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
|
||||
.row-block .inp {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.diagbox {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--br);
|
||||
border-radius: 8px;
|
||||
background: var(--dim);
|
||||
color: var(--fg);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tone-pref-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tone-swatch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tone-swatch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tone-circle {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--br);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
.tone-default {
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, transparent 0 28%, rgba(255,255,255,.7) 29% 34%, transparent 35%),
|
||||
linear-gradient(180deg, #d7e0eb 0%, #a4b2c3 100%);
|
||||
}
|
||||
|
||||
.tone-1 { background: #f7d7c4; }
|
||||
.tone-2 { background: #e8bf95; }
|
||||
.tone-3 { background: #c68642; }
|
||||
.tone-4 { background: #8d5524; }
|
||||
.tone-5 { background: #5e3b22; }
|
||||
|
||||
.tone-swatch input:checked + .tone-circle {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.tone-swatch input:focus-visible + .tone-circle {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bubble-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.bubble-kv {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--br);
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bubble-kv > span:last-child {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bubble-kv .ok { color: #059669; }
|
||||
.bubble-kv .warn { color: #d97706; }
|
||||
.bubble-kv .bad { color: #dc2626; }
|
||||
|
||||
.alert {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--br);
|
||||
background: var(--dim);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.alert strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
border-color: rgba(220, 38, 38, .35);
|
||||
}
|
||||
|
||||
.alert.warn {
|
||||
border-color: rgba(217, 119, 6, .35);
|
||||
}
|
||||
|
||||
.autoload-sentinel {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.card.private {
|
||||
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, .35);
|
||||
}
|
||||
|
||||
.card.private .nm::after {
|
||||
content: " · private";
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.card.toneable::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: #60a5fa;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--br);
|
||||
background: var(--dim);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tone-option {
|
||||
min-height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--br);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.tone-option.selected {
|
||||
box-shadow: inset 0 0 0 2px #60a5fa;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.tone-option.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tone-note {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 12px;
|
||||
color: var(--mut);
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 64px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.card .emo {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.card .nm {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card {
|
||||
min-height: 52px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card .emo {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.bubble-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.tab {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.card {
|
||||
min-height: 54px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.card .emo {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card .nm {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card .emo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.tone-option {
|
||||
min-height: 38px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
1892
dewemoji-capacitor/src/app.js
Normal file
182
dewemoji-capacitor/src/index.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Dewemoji</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b1220" />
|
||||
<link rel="preconnect" href="https://twemoji.maxcdn.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
|
||||
<link href="app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="theme-dark">
|
||||
<header class="hdr">
|
||||
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Guest</span></h1>
|
||||
<div class="bar">
|
||||
<input id="q" class="inp" type="search" placeholder="Search (e.g. love)" aria-label="Search emojis" />
|
||||
<button id="clear" class="btn icon" title="Clear" aria-label="Clear">✕</button>
|
||||
<button id="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
|
||||
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filters">
|
||||
<select id="cat" class="sel" aria-label="Category filter">
|
||||
<option value="">All categories</option>
|
||||
</select>
|
||||
<select id="sub" class="sel" aria-label="Subcategory filter" disabled>
|
||||
<option value="">All subcategories</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<main id="list" class="grid" aria-live="polite"></main>
|
||||
<div id="autoload-sentinel" class="autoload-sentinel" aria-hidden="true"></div>
|
||||
|
||||
<footer class="ft">
|
||||
<button id="more" class="btn w">Load more</button>
|
||||
<span id="count" class="muted nowrap">0 items</span>
|
||||
<span id="ver" class="badge nowrap">APK</span>
|
||||
</footer>
|
||||
|
||||
<div id="sheet-backdrop" class="backdrop" hidden></div>
|
||||
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||
<div class="sheet-head">
|
||||
<h3 id="settings-title">Settings</h3>
|
||||
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
<div class="tabs" role="tablist" aria-label="Settings tabs">
|
||||
<button class="tab active" data-tab="general" role="tab" aria-selected="true">General</button>
|
||||
<button class="tab" data-tab="account" role="tab" aria-selected="false">Account</button>
|
||||
<button class="tab" data-tab="bubble" role="tab" aria-selected="false">Bubble</button>
|
||||
</div>
|
||||
|
||||
<section id="tab-general" class="tabpane active" role="tabpanel">
|
||||
<div class="field">
|
||||
<label class="lbl">Instant Access</label>
|
||||
<p class="hint muted">Tap an emoji to copy. Then switch back and paste in the app you are typing in.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl">Current Session</label>
|
||||
<div class="diagbox" id="session-summary">Guest mode. Public keywords only.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl">Grid display</label>
|
||||
<label class="radio row-inline" for="show-emoji-names-toggle">
|
||||
<input id="show-emoji-names-toggle" type="checkbox" />
|
||||
<span>Show emoji names under each emoji</span>
|
||||
</label>
|
||||
<p class="hint muted">Off by default for larger emoji and faster scanning.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl">Skin tone</label>
|
||||
<label class="radio row-inline" for="tone-lock-toggle">
|
||||
<input id="tone-lock-toggle" type="checkbox" />
|
||||
<span>Use preferred skin tone on tap (tone lock)</span>
|
||||
</label>
|
||||
<div class="row row-block" style="margin-top:8px;">
|
||||
<div id="preferred-skin-tone-radios" class="tone-pref-group" role="radiogroup" aria-label="Preferred skin tone">
|
||||
<label class="tone-swatch" title="Default (no tone)">
|
||||
<input type="radio" name="preferredSkinTone" value="0" aria-label="Default (no tone)" />
|
||||
<span class="tone-circle tone-default" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Light skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="1" aria-label="Light skin tone" />
|
||||
<span class="tone-circle tone-1" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Medium-light skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="2" aria-label="Medium-light skin tone" />
|
||||
<span class="tone-circle tone-2" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Medium skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="3" aria-label="Medium skin tone" />
|
||||
<span class="tone-circle tone-3" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Medium-dark skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="4" aria-label="Medium-dark skin tone" />
|
||||
<span class="tone-circle tone-4" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Dark skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="5" aria-label="Dark skin tone" />
|
||||
<span class="tone-circle tone-5" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint muted">Long press an emoji with skin tones to choose a tone quickly.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl">Refresh catalog</label>
|
||||
<div class="row row-inline">
|
||||
<button id="refresh" class="btn">Refresh</button>
|
||||
<span id="api-status" class="muted">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-account" class="tabpane" role="tabpanel">
|
||||
<div class="field">
|
||||
<label class="lbl">Account</label>
|
||||
<div id="account-connect-form">
|
||||
<div class="row row-block">
|
||||
<input id="account-email" class="inp" placeholder="Email" type="email" autocomplete="username" />
|
||||
</div>
|
||||
<div class="row row-block" style="margin-top:6px;">
|
||||
<input id="account-password" class="inp" placeholder="Password" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="row row-inline" style="margin-top:8px;">
|
||||
<button id="account-login" class="btn">Connect</button>
|
||||
</div>
|
||||
<div class="row row-inline" style="margin-top:6px;">
|
||||
<span id="account-status" class="muted">Not connected. Public keywords only.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="account-connected" style="display:none;">
|
||||
<div class="row row-inline">
|
||||
<span id="account-greeting" class="muted">Connected.</span>
|
||||
</div>
|
||||
<div class="row row-inline" style="margin-top:8px;">
|
||||
<button id="account-logout" class="btn ghost">Logout</button>
|
||||
</div>
|
||||
<div class="row row-inline" style="margin-top:6px;">
|
||||
<span class="muted">Private keyword matches appear in search when available.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-bubble" class="tabpane" role="tabpanel">
|
||||
<div class="field">
|
||||
<label class="lbl">Floating bubble (quick search + copy)</label>
|
||||
<p class="hint muted">Use Dewemoji bubble to quickly search and copy emoji while using other apps. Paste manually in the app you’re typing in.</p>
|
||||
</div>
|
||||
|
||||
<div class="bubble-grid">
|
||||
<div class="bubble-kv"><span class="muted">Platform</span><span id="bubble-platform-status">Checking…</span></div>
|
||||
<div class="bubble-kv"><span class="muted">Overlay permission</span><span id="bubble-overlay-status">Checking…</span></div>
|
||||
<div class="bubble-kv"><span class="muted">Notifications</span><span id="bubble-notify-status">Checking…</span></div>
|
||||
<div class="bubble-kv"><span class="muted">Bubble service</span><span id="bubble-running-status">Checking…</span></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="row row-wrap">
|
||||
<button id="bubble-enable-btn" class="btn">Enable bubble</button>
|
||||
<button id="bubble-disable-btn" class="btn ghost">Disable bubble</button>
|
||||
</div>
|
||||
<div class="row row-wrap" style="margin-top:8px;">
|
||||
<button id="bubble-overlay-settings-btn" class="btn ghost">Grant overlay permission</button>
|
||||
<button id="bubble-notify-settings-btn" class="btn ghost">Open notification settings</button>
|
||||
</div>
|
||||
<div class="diagbox" id="bubble-help" style="margin-top:8px;">Bubble is off by default. It opens Dewemoji for quick search and copy.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script src="twemoji-lite.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
dewemoji-capacitor/src/twemoji-lite.js
Normal file
@@ -0,0 +1,31 @@
|
||||
(() => {
|
||||
const root = window;
|
||||
if (root?.twemoji?.convert?.toCodePoint) return;
|
||||
|
||||
function toCodePoint(unicodeSurrogates, sep = '-') {
|
||||
const r = [];
|
||||
let c = 0;
|
||||
let p = 0;
|
||||
let i = 0;
|
||||
const s = String(unicodeSurrogates || '');
|
||||
|
||||
while (i < s.length) {
|
||||
c = s.charCodeAt(i++);
|
||||
if (p) {
|
||||
r.push((0x10000 + ((p - 0xD800) * 0x400) + (c - 0xDC00)).toString(16));
|
||||
p = 0;
|
||||
} else if (c >= 0xD800 && c <= 0xDBFF) {
|
||||
p = c;
|
||||
} else {
|
||||
r.push(c.toString(16));
|
||||
}
|
||||
}
|
||||
|
||||
return r.join(sep);
|
||||
}
|
||||
|
||||
root.twemoji = root.twemoji || {};
|
||||
root.twemoji.convert = root.twemoji.convert || {};
|
||||
root.twemoji.convert.toCodePoint = toCodePoint;
|
||||
})();
|
||||
|
||||
967
dewemoji-capacitor/www/app.css
Normal file
@@ -0,0 +1,967 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb; --active: #60a5fa2b;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
|
||||
}
|
||||
/* explicit override by class */
|
||||
.theme-light {
|
||||
color-scheme: light;
|
||||
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
|
||||
}
|
||||
.theme-dark {
|
||||
color-scheme: dark;
|
||||
--bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151;
|
||||
}
|
||||
|
||||
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
|
||||
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
|
||||
|
||||
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); z-index: 9;}
|
||||
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
|
||||
.bar { display:flex; gap:6px; }
|
||||
.inp { flex:1; padding:8px 8px; border:1px solid var(--br); border-radius:8px; background:var(--bg); color:var(--fg); }
|
||||
/* buttons */
|
||||
.btn { padding:8px 10px; border:1px solid var(--br); border-radius:8px; background:var(--dim); cursor:pointer; }
|
||||
.btn:hover { filter: brightness(0.98); }
|
||||
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
|
||||
|
||||
/* disabled buttons (e.g., Load more) */
|
||||
.btn[disabled] { opacity:.55; cursor:not-allowed; }
|
||||
|
||||
.filters { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:8px 10px; border-bottom:1px solid var(--br); }
|
||||
.grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:8px; padding:10px; }
|
||||
|
||||
.card {
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:6px; padding:10px; border:1px solid var(--br); border-radius:12px; background:var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card .emo { font-size:28px; line-height:1; }
|
||||
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
.ft { display:flex; align-items:center; justify-content: space-between; gap:8px; padding:10px; border-top:1px solid var(--br);
|
||||
position:sticky; bottom:0; background:var(--bg); }
|
||||
.muted { color:var(--mut); }
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
|
||||
background: var(--dim); color: var(--fg); padding: 8px 12px; border-radius: 8px;
|
||||
border:1px solid var(--br); opacity: 0; pointer-events:none; transition: opacity .18s ease;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
.sel {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--br);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
appearance: none; /* cleaner look */
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--mut) 50%),
|
||||
linear-gradient(135deg, var(--mut) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% - 3px),
|
||||
calc(100% - 12px) calc(50% - 3px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.sel:disabled { opacity: .6; }
|
||||
.badge {
|
||||
padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
|
||||
background: var(--dim); color: var(--fg); border:1px solid var(--br);
|
||||
}
|
||||
|
||||
/* Backdrop + Sheet (modal) */
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.35);
|
||||
z-index: 80; opacity: 0; transition: opacity .18s ease;
|
||||
}
|
||||
.backdrop.show { opacity: 1; }
|
||||
|
||||
.sheet {
|
||||
position: fixed; right: 12px; bottom: 12px; left: 12px; max-width: 520px;
|
||||
margin: 0 auto; z-index: 81; background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--br); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
||||
transform: translateY(8px); opacity: 0; transition: transform .18s ease, opacity .18s ease;
|
||||
}
|
||||
.sheet.show { transform: translateY(0); opacity: 1; }
|
||||
.sheet-head { display:flex; align-items:center; justify-content:space-between; padding:12px; border-bottom:1px solid var(--br); border-radius: inherit}
|
||||
.sheet-body { padding:12px; display:grid; gap:14px; }
|
||||
.field .lbl { display:block; font-weight:600; margin-bottom:6px; }
|
||||
.field .hint { margin-top:6px; font-size:12px; }
|
||||
.row { display:flex; gap:8px; }
|
||||
|
||||
/* Radios */
|
||||
.radios { display:grid; gap:8px; }
|
||||
.radio { display:flex; align-items:center; gap:8px; padding:8px; border:1px solid var(--br); border-radius:8px; background: var(--bg); }
|
||||
.radio input[type="radio"] { accent-color: #3b82f6; }
|
||||
.radio[aria-disabled="true"] { opacity:.55; }
|
||||
|
||||
.tag {
|
||||
margin-left:auto; font-size:11px; padding:2px 8px; border-radius:9999px;
|
||||
border:1px solid var(--br); background: var(--dim); color: var(--fg);
|
||||
}
|
||||
|
||||
/* Badge (version) already exists — keep it; this is just a reminder */
|
||||
.badge { padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
|
||||
background: var(--dim); color: var(--fg); border:1px solid var(--br); }
|
||||
|
||||
/* Buttons & inputs already set; ensure icon buttons look good */
|
||||
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
|
||||
.btn[disabled] { opacity:.55; cursor:not-allowed; }
|
||||
|
||||
.diagbox {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--br);
|
||||
border-radius: 8px;
|
||||
background: var(--dim);
|
||||
color: var(--fg);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* apply to the glyph container you use */
|
||||
.emo, .emo * {
|
||||
font-family:
|
||||
"Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji",
|
||||
"Twemoji Mozilla", "EmojiOne Color", "Segoe UI Symbol",
|
||||
system-ui, sans-serif !important;
|
||||
font-variant-emoji: emoji;
|
||||
line-height: 1;
|
||||
}
|
||||
.emo img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#dewemoji-status {
|
||||
float: right;
|
||||
}
|
||||
/* Settings tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--br);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--dim);
|
||||
border: 1px solid var(--br);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab:not(.active) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
opacity: 1;
|
||||
}
|
||||
.tabpane { display: none; }
|
||||
.tabpane.active { display: block; }
|
||||
|
||||
/* Tone palette theming */
|
||||
.theme-light, :root.theme-light {
|
||||
--c-bg: var(--bg, #ffffff);
|
||||
--c-chip: var(--dim, #f3f4f6);
|
||||
--c-border: var(--br, #e5e7eb);
|
||||
}
|
||||
.theme-dark, :root.theme-dark {
|
||||
--c-bg: var(--bg, #0f172a);
|
||||
--c-chip: var(--dim, #111827);
|
||||
--c-border: var(--br, #374151);
|
||||
}
|
||||
|
||||
/* Accent color for focus/selection rings */
|
||||
.theme-light, :root.theme-light { --accent: #3b82f6; }
|
||||
.theme-dark, :root.theme-dark { --accent: #60a5fa; }
|
||||
|
||||
/* Tone palette buttons (popover + settings) */
|
||||
.tone-btn, .tone-chip {
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-chip);
|
||||
}
|
||||
.tone-btn.selected, .tone-chip.selected {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent) !important;
|
||||
}
|
||||
.tone-btn:focus-visible, .tone-chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ===== Settings sheet polish ===== */
|
||||
.sheet { max-width: 540px; }
|
||||
.sheet-head {
|
||||
position: sticky; top: 0; background: var(--bg);
|
||||
z-index: 2; border-bottom: 1px solid var(--br);
|
||||
}
|
||||
.sheet-head h3 { margin: 0; }
|
||||
|
||||
.sheet-body { padding-top: 10px; }
|
||||
.field { margin: 12px 0 16px; }
|
||||
.field .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
|
||||
|
||||
/* Compact rows */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto; /* input | Activate | Deactivate */
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.row + .row { margin-top: 6px; }
|
||||
|
||||
/* Inputs and small buttons */
|
||||
.inp { padding: 6px 10px; }
|
||||
.btn.sm { padding: 6px 10px; font-size: 12px; line-height: 1; }
|
||||
.btn.ghost { background: transparent; border: 1px solid var(--br); }
|
||||
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
|
||||
/* Tabs = segmented control */
|
||||
.tabs {
|
||||
background: var(--dim); padding: 4px; border-radius: 10px;
|
||||
display: inline-flex; gap: 4px; border: 1px solid var(--br);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tab { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-weight: 600; }
|
||||
.tab.active { background: var(--bg); box-shadow: inset 0 0 0 1px var(--br); }
|
||||
|
||||
/* License subtext wraps neatly */
|
||||
#license-status { display: inline-block; margin-left: 8px; }
|
||||
|
||||
/* Tone palette spacin.g + buttons */
|
||||
.tone-palette { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tone-chip {
|
||||
min-width: 40px; height: 36px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px; border: 1px solid var(--c-border); background: var(--c-chip);
|
||||
}
|
||||
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
||||
|
||||
/* Toast a tad higher so it doesn’t overlap sheet */
|
||||
.toast { bottom: 18px; }
|
||||
|
||||
/* Make the top controls a proper stacking context above the grid */
|
||||
.topbar, .filters-row {
|
||||
position: sticky; /* or relative if not sticky */
|
||||
z-index: 5;
|
||||
background: var(--c-bg, #0f172a); /* ensure it has a solid bg in dark & light */
|
||||
}
|
||||
|
||||
/* Or ensure the select dropdown sits above adjacent icons */
|
||||
select#sub {
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
div.card.active {
|
||||
background-color: var(--active)!important;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-column: 1 / -1;
|
||||
background-color: var(--active);
|
||||
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0px, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tone-option {
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
|
||||
background: var(--c-bg, #f3f4f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
/* ===== APK companion overrides (extension-like, denser) ===== */
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
padding-top: max(0px, env(safe-area-inset-top));
|
||||
padding-bottom: max(0px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.hdr {
|
||||
padding-top: calc(10px + env(safe-area-inset-top) * 0.25);
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
min-height: calc(100vh - 170px);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body.overlay-mode .grid {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
html.overlay-mode,
|
||||
body.overlay-mode {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
body.overlay-mode {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
body.overlay-mode .hdr,
|
||||
body.overlay-mode .ft {
|
||||
position: static;
|
||||
}
|
||||
|
||||
body.overlay-mode #theme {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.overlay-mode #settings-sheet,
|
||||
body.overlay-mode #sheet-backdrop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.overlay-mode {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body.overlay-mode .bar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body.overlay-mode .filters {
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
body.overlay-mode .inp,
|
||||
body.overlay-mode .sel {
|
||||
min-height: 48px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body.overlay-mode .btn {
|
||||
min-height: 46px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body.overlay-mode .btn.icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
body.overlay-mode .btn.w {
|
||||
min-width: 124px;
|
||||
}
|
||||
|
||||
body.overlay-mode .ft {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .sheet {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
max-width: none;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 44px rgba(0,0,0,.28);
|
||||
}
|
||||
|
||||
body.overlay-mode .sheet-head {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
body.overlay-mode .sheet-head h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
body.overlay-mode .sheet-body {
|
||||
padding: 14px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
body.overlay-mode .field {
|
||||
margin: 14px 0 18px;
|
||||
}
|
||||
|
||||
body.overlay-mode .field .lbl {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
body.overlay-mode .field .hint,
|
||||
body.overlay-mode .diagbox,
|
||||
body.overlay-mode .muted {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
body.overlay-mode .diagbox {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .row,
|
||||
body.overlay-mode .row-inline,
|
||||
body.overlay-mode .row-wrap {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .row-inline {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
body.overlay-mode .row-wrap .btn,
|
||||
body.overlay-mode .row-inline .btn {
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
body.overlay-mode #account-connect-form .row-block .inp,
|
||||
body.overlay-mode #account-connected .row-inline .btn {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tabs {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tab {
|
||||
min-height: 44px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body.overlay-mode .radio {
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-pref-group {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-swatch {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-circle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
body.overlay-mode .bubble-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .bubble-kv {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body.overlay-mode .bubble-kv > span:last-child {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-row {
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
body.overlay-mode .tone-option {
|
||||
min-height: 48px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse), (max-width: 900px) {
|
||||
body:not(.overlay-mode) .inp,
|
||||
body:not(.overlay-mode) .sel {
|
||||
min-height: 46px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .btn.icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .filters {
|
||||
gap: 8px;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .bar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .ft {
|
||||
gap: 10px;
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .sheet {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .sheet-head {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .sheet-head h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .sheet-body {
|
||||
padding: 14px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .field {
|
||||
margin: 14px 0 18px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .row,
|
||||
body:not(.overlay-mode) .row-inline,
|
||||
body:not(.overlay-mode) .row-wrap {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .row-inline {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .tabs {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .tab {
|
||||
min-height: 44px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .radio {
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) #account-connect-form .row-block .inp,
|
||||
body:not(.overlay-mode) #account-connected .row-inline .btn {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
body:not(.overlay-mode) .bubble-kv {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 72px;
|
||||
padding: 8px 6px;
|
||||
gap: 4px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.card * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.card .emo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card .nm {
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card {
|
||||
min-height: 58px;
|
||||
gap: 2px;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card .nm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card .emo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.btn.w {
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.ft {
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom) * 0.5);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.row-inline {
|
||||
display: flex;
|
||||
grid-template-columns: none;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-wrap {
|
||||
display: flex;
|
||||
grid-template-columns: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row-block {
|
||||
display: block;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
|
||||
.row-block .inp {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.diagbox {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--br);
|
||||
border-radius: 8px;
|
||||
background: var(--dim);
|
||||
color: var(--fg);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tone-pref-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tone-swatch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tone-swatch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tone-circle {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--br);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
.tone-default {
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, transparent 0 28%, rgba(255,255,255,.7) 29% 34%, transparent 35%),
|
||||
linear-gradient(180deg, #d7e0eb 0%, #a4b2c3 100%);
|
||||
}
|
||||
|
||||
.tone-1 { background: #f7d7c4; }
|
||||
.tone-2 { background: #e8bf95; }
|
||||
.tone-3 { background: #c68642; }
|
||||
.tone-4 { background: #8d5524; }
|
||||
.tone-5 { background: #5e3b22; }
|
||||
|
||||
.tone-swatch input:checked + .tone-circle {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.tone-swatch input:focus-visible + .tone-circle {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bubble-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.bubble-kv {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--br);
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bubble-kv > span:last-child {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bubble-kv .ok { color: #059669; }
|
||||
.bubble-kv .warn { color: #d97706; }
|
||||
.bubble-kv .bad { color: #dc2626; }
|
||||
|
||||
.alert {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--br);
|
||||
background: var(--dim);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.alert strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
border-color: rgba(220, 38, 38, .35);
|
||||
}
|
||||
|
||||
.alert.warn {
|
||||
border-color: rgba(217, 119, 6, .35);
|
||||
}
|
||||
|
||||
.autoload-sentinel {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.card.private {
|
||||
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, .35);
|
||||
}
|
||||
|
||||
.card.private .nm::after {
|
||||
content: " · private";
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.card.toneable::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: #60a5fa;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--br);
|
||||
background: var(--dim);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tone-option {
|
||||
min-height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--br);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.tone-option.selected {
|
||||
box-shadow: inset 0 0 0 2px #60a5fa;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.tone-option.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tone-note {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 12px;
|
||||
color: var(--mut);
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 64px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.card .emo {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.card .nm {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card {
|
||||
min-height: 52px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card .emo {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.bubble-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.tab {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.card {
|
||||
min-height: 54px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.card .emo {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card .nm {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
body.emoji-names-hidden .card .emo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.tone-option {
|
||||
min-height: 38px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
1892
dewemoji-capacitor/www/app.js
Normal file
182
dewemoji-capacitor/www/index.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Dewemoji</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b1220" />
|
||||
<link rel="preconnect" href="https://twemoji.maxcdn.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
|
||||
<link href="app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="theme-dark">
|
||||
<header class="hdr">
|
||||
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Guest</span></h1>
|
||||
<div class="bar">
|
||||
<input id="q" class="inp" type="search" placeholder="Search (e.g. love)" aria-label="Search emojis" />
|
||||
<button id="clear" class="btn icon" title="Clear" aria-label="Clear">✕</button>
|
||||
<button id="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
|
||||
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filters">
|
||||
<select id="cat" class="sel" aria-label="Category filter">
|
||||
<option value="">All categories</option>
|
||||
</select>
|
||||
<select id="sub" class="sel" aria-label="Subcategory filter" disabled>
|
||||
<option value="">All subcategories</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<main id="list" class="grid" aria-live="polite"></main>
|
||||
<div id="autoload-sentinel" class="autoload-sentinel" aria-hidden="true"></div>
|
||||
|
||||
<footer class="ft">
|
||||
<button id="more" class="btn w">Load more</button>
|
||||
<span id="count" class="muted nowrap">0 items</span>
|
||||
<span id="ver" class="badge nowrap">APK</span>
|
||||
</footer>
|
||||
|
||||
<div id="sheet-backdrop" class="backdrop" hidden></div>
|
||||
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||
<div class="sheet-head">
|
||||
<h3 id="settings-title">Settings</h3>
|
||||
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
<div class="tabs" role="tablist" aria-label="Settings tabs">
|
||||
<button class="tab active" data-tab="general" role="tab" aria-selected="true">General</button>
|
||||
<button class="tab" data-tab="account" role="tab" aria-selected="false">Account</button>
|
||||
<button class="tab" data-tab="bubble" role="tab" aria-selected="false">Bubble</button>
|
||||
</div>
|
||||
|
||||
<section id="tab-general" class="tabpane active" role="tabpanel">
|
||||
<div class="field">
|
||||
<label class="lbl">Instant Access</label>
|
||||
<p class="hint muted">Tap an emoji to copy. Then switch back and paste in the app you are typing in.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl">Current Session</label>
|
||||
<div class="diagbox" id="session-summary">Guest mode. Public keywords only.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl">Grid display</label>
|
||||
<label class="radio row-inline" for="show-emoji-names-toggle">
|
||||
<input id="show-emoji-names-toggle" type="checkbox" />
|
||||
<span>Show emoji names under each emoji</span>
|
||||
</label>
|
||||
<p class="hint muted">Off by default for larger emoji and faster scanning.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl">Skin tone</label>
|
||||
<label class="radio row-inline" for="tone-lock-toggle">
|
||||
<input id="tone-lock-toggle" type="checkbox" />
|
||||
<span>Use preferred skin tone on tap (tone lock)</span>
|
||||
</label>
|
||||
<div class="row row-block" style="margin-top:8px;">
|
||||
<div id="preferred-skin-tone-radios" class="tone-pref-group" role="radiogroup" aria-label="Preferred skin tone">
|
||||
<label class="tone-swatch" title="Default (no tone)">
|
||||
<input type="radio" name="preferredSkinTone" value="0" aria-label="Default (no tone)" />
|
||||
<span class="tone-circle tone-default" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Light skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="1" aria-label="Light skin tone" />
|
||||
<span class="tone-circle tone-1" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Medium-light skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="2" aria-label="Medium-light skin tone" />
|
||||
<span class="tone-circle tone-2" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Medium skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="3" aria-label="Medium skin tone" />
|
||||
<span class="tone-circle tone-3" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Medium-dark skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="4" aria-label="Medium-dark skin tone" />
|
||||
<span class="tone-circle tone-4" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label class="tone-swatch" title="Dark skin tone">
|
||||
<input type="radio" name="preferredSkinTone" value="5" aria-label="Dark skin tone" />
|
||||
<span class="tone-circle tone-5" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint muted">Long press an emoji with skin tones to choose a tone quickly.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl">Refresh catalog</label>
|
||||
<div class="row row-inline">
|
||||
<button id="refresh" class="btn">Refresh</button>
|
||||
<span id="api-status" class="muted">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-account" class="tabpane" role="tabpanel">
|
||||
<div class="field">
|
||||
<label class="lbl">Account</label>
|
||||
<div id="account-connect-form">
|
||||
<div class="row row-block">
|
||||
<input id="account-email" class="inp" placeholder="Email" type="email" autocomplete="username" />
|
||||
</div>
|
||||
<div class="row row-block" style="margin-top:6px;">
|
||||
<input id="account-password" class="inp" placeholder="Password" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="row row-inline" style="margin-top:8px;">
|
||||
<button id="account-login" class="btn">Connect</button>
|
||||
</div>
|
||||
<div class="row row-inline" style="margin-top:6px;">
|
||||
<span id="account-status" class="muted">Not connected. Public keywords only.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="account-connected" style="display:none;">
|
||||
<div class="row row-inline">
|
||||
<span id="account-greeting" class="muted">Connected.</span>
|
||||
</div>
|
||||
<div class="row row-inline" style="margin-top:8px;">
|
||||
<button id="account-logout" class="btn ghost">Logout</button>
|
||||
</div>
|
||||
<div class="row row-inline" style="margin-top:6px;">
|
||||
<span class="muted">Private keyword matches appear in search when available.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-bubble" class="tabpane" role="tabpanel">
|
||||
<div class="field">
|
||||
<label class="lbl">Floating bubble (quick search + copy)</label>
|
||||
<p class="hint muted">Use Dewemoji bubble to quickly search and copy emoji while using other apps. Paste manually in the app you’re typing in.</p>
|
||||
</div>
|
||||
|
||||
<div class="bubble-grid">
|
||||
<div class="bubble-kv"><span class="muted">Platform</span><span id="bubble-platform-status">Checking…</span></div>
|
||||
<div class="bubble-kv"><span class="muted">Overlay permission</span><span id="bubble-overlay-status">Checking…</span></div>
|
||||
<div class="bubble-kv"><span class="muted">Notifications</span><span id="bubble-notify-status">Checking…</span></div>
|
||||
<div class="bubble-kv"><span class="muted">Bubble service</span><span id="bubble-running-status">Checking…</span></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="row row-wrap">
|
||||
<button id="bubble-enable-btn" class="btn">Enable bubble</button>
|
||||
<button id="bubble-disable-btn" class="btn ghost">Disable bubble</button>
|
||||
</div>
|
||||
<div class="row row-wrap" style="margin-top:8px;">
|
||||
<button id="bubble-overlay-settings-btn" class="btn ghost">Grant overlay permission</button>
|
||||
<button id="bubble-notify-settings-btn" class="btn ghost">Open notification settings</button>
|
||||
</div>
|
||||
<div class="diagbox" id="bubble-help" style="margin-top:8px;">Bubble is off by default. It opens Dewemoji for quick search and copy.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script src="twemoji-lite.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
dewemoji-capacitor/www/twemoji-lite.js
Normal file
@@ -0,0 +1,31 @@
|
||||
(() => {
|
||||
const root = window;
|
||||
if (root?.twemoji?.convert?.toCodePoint) return;
|
||||
|
||||
function toCodePoint(unicodeSurrogates, sep = '-') {
|
||||
const r = [];
|
||||
let c = 0;
|
||||
let p = 0;
|
||||
let i = 0;
|
||||
const s = String(unicodeSurrogates || '');
|
||||
|
||||
while (i < s.length) {
|
||||
c = s.charCodeAt(i++);
|
||||
if (p) {
|
||||
r.push((0x10000 + ((p - 0xD800) * 0x400) + (c - 0xDC00)).toString(16));
|
||||
p = 0;
|
||||
} else if (c >= 0xD800 && c <= 0xDBFF) {
|
||||
p = c;
|
||||
} else {
|
||||
r.push(c.toString(16));
|
||||
}
|
||||
}
|
||||
|
||||
return r.join(sep);
|
||||
}
|
||||
|
||||
root.twemoji = root.twemoji || {};
|
||||
root.twemoji.convert = root.twemoji.convert || {};
|
||||
root.twemoji.convert.toCodePoint = toCodePoint;
|
||||
})();
|
||||
|
||||
@@ -1,641 +1,142 @@
|
||||
# Dewemoji Product Direction & Strategy Brief
|
||||
# Dewemoji Product Direction (2026)
|
||||
|
||||
**Version:** 2.0
|
||||
**Date:** February 8, 2026
|
||||
**Author:** Dwindi (Project Owner)
|
||||
## Product decision snapshot
|
||||
|
||||
---
|
||||
Dewemoji is a **personal emoji library** product:
|
||||
|
||||
## Executive Summary
|
||||
- Free: public emoji discovery (EN/ID dataset)
|
||||
- Paid (`Personal`): private keyword library + sync + account features
|
||||
- Main value: "your words -> your emojis"
|
||||
|
||||
Dewemoji pivots from a public community-driven emoji dictionary to a **personal emoji library platform** where users build, sync, and own their keyword vocabularies across devices. This eliminates moderation overhead, AI costs, and public abuse risks while monetizing the core unique value: **private, multilingual keyword customization + seamless sync**.
|
||||
Tagline:
|
||||
|
||||
**New Tagline:** *"Your words → your emojis, anywhere"*
|
||||
- "Your words -> your emojis, anywhere"
|
||||
|
||||
---
|
||||
## What is in scope now
|
||||
|
||||
## 1. Vision & Mission
|
||||
### Core model
|
||||
|
||||
### Vision
|
||||
Enable everyone to find and use emojis using *their* language, slang, and personal vocabulary—without language barriers or platform limitations.
|
||||
1. Public search remains open and fast.
|
||||
2. Private keywords are user-owned and synced.
|
||||
3. Paid conversion is driven by personalization value, not by limiting discovery.
|
||||
|
||||
### Mission
|
||||
Provide a semantic emoji discovery platform that:
|
||||
- Offers unlimited free public search (EN/ID + semantic matching)
|
||||
- Lets users create private, personal keyword libraries (any language, any script)
|
||||
- Syncs personal libraries across web, extensions, and future apps
|
||||
- Removes friction from emoji usage in daily communication
|
||||
### Tier model
|
||||
|
||||
### Core Problem Being Solved
|
||||
"I spent too much time finding an emoji because I think in my language/slang ('bekicot' for 🐌), and most tools only understand English keywords."
|
||||
- Free: unlimited public search, copy/insert, skin tone utilities
|
||||
- Personal: private keywords, keyword sync, API keys, dashboard access
|
||||
|
||||
---
|
||||
Pricing target used in planning:
|
||||
|
||||
## 2. Strategic Pivot: Why Private-Only Keywords
|
||||
- Monthly: `$4.99`
|
||||
- Annual: `$49`
|
||||
- Lifetime: `$99`
|
||||
|
||||
### Previous Model (Abandoned)
|
||||
- Public community keyword submissions
|
||||
- Voting/moderation for global searchability
|
||||
- AI moderation for toxicity
|
||||
- SEO feeding from user keywords
|
||||
## UX direction
|
||||
|
||||
### Problems with Previous Model
|
||||
1. **AI moderation costs** haunting scalability
|
||||
2. **Moral responsibility** for public bad words feeding internet
|
||||
3. **Unclear monetization** (who pays for emoji stuff?)
|
||||
4. **Complexity** managing voting, thresholds, public curation
|
||||
### User states
|
||||
|
||||
### New Model (Adopted)
|
||||
- **Fully private user keyword libraries**
|
||||
- No public submissions, no voting, no AI moderation
|
||||
- Users own and control their keywords (stored per account)
|
||||
- Public search stays free and unlimited (referrer-whitelisted)
|
||||
|
||||
### Benefits
|
||||
✅ **Zero moderation burden** (private = user's responsibility)
|
||||
✅ **No AI costs** (no content filtering needed)
|
||||
✅ **Clear monetization** (pay for personal sync, not public features)
|
||||
✅ **Moral simplicity** (no liability for user's private words)
|
||||
✅ **Technical simplicity** (CRUD keywords per user_id)
|
||||
|
||||
---
|
||||
1. Visitor: discover and use immediately (no login wall)
|
||||
2. Free logged-in user: sees upgrade paths where personalization would help
|
||||
3. Personal user: quick add on detail pages + full dashboard management
|
||||
|
||||
## 3. Monetization Model
|
||||
### Primary flow
|
||||
|
||||
### Core Principle
|
||||
**"Free discovery → Paid personalization"**
|
||||
1. User discovers emoji from public search.
|
||||
2. Personal user adds custom keyword from emoji detail page.
|
||||
3. Keyword becomes immediately searchable for that user.
|
||||
4. Dashboard handles bulk CRUD/import/export/API key management.
|
||||
|
||||
Users get unlimited free public search to discover and love the product, then upgrade to sync their personal vocabulary.
|
||||
|
||||
### Tiers
|
||||
|
||||
| Feature | Free | Personal ($4.99/mo) |
|
||||
|---------|------|---------------------|
|
||||
| **Public emoji search** | ✅ Unlimited (EN/ID keywords) | ✅ Unlimited |
|
||||
| **Search result limit** | ✅ No limits | ✅ No limits |
|
||||
| **Skintone variations** | ✅ Free | ✅ Free |
|
||||
| **Copy to clipboard** | ✅ Free | ✅ Free |
|
||||
| **Auto-Insert to page** | ✅ Free (viral hook!) | ✅ Free |
|
||||
| **Automatic mode** | ✅ Free | ✅ Free |
|
||||
| **Private keywords** | ❌ Not available | ✅ Unlimited CRUD |
|
||||
| **Keyword sync** | ❌ Not available | ✅ Cross-device/app |
|
||||
| **API keys** | ❌ Not available | ✅ Generate/revoke |
|
||||
| **Dashboard access** | ❌ Not available | ✅ Full management |
|
||||
|
||||
### Tier Naming
|
||||
- **Free** → stays "Free"
|
||||
- **Pro** → renamed to **"Personal"** (reflects private/personal keywords, not business/pro tools)
|
||||
|
||||
### Pricing Options
|
||||
- **Monthly:** $4.99/month (Stripe subscription)
|
||||
- **Annual:** $49/year (~$4.08/month, 17% savings)
|
||||
- **Lifetime:** $99 (one-time, early adopter offer)
|
||||
|
||||
### Payment Flexibility
|
||||
Self-generated API keys mean **any payment provider works**:
|
||||
- Stripe (primary, global)
|
||||
- Paddle (backup)
|
||||
- Midtrans/Xendit (Indonesia localization)
|
||||
- No dependency on Gumroad license keys
|
||||
|
||||
---
|
||||
|
||||
## 4. Channel Roles & User Flows
|
||||
|
||||
### 4.1 Website (dewemoji.com)
|
||||
|
||||
#### Non-Logged Users
|
||||
**Purpose:** Free discovery & viral entry point
|
||||
|
||||
**Features:**
|
||||
- Search emojis by public keywords (EN/ID)
|
||||
- Browse by category/subcategory
|
||||
- Copy emojis to clipboard
|
||||
- View emoji details (unified, codepoints, shortcodes)
|
||||
- No account required
|
||||
|
||||
**CTA:** "Want your own keywords like 'bekicot'? → Sign Up Free"
|
||||
|
||||
#### Logged Users (Free Account)
|
||||
**Purpose:** Dashboard teaser
|
||||
|
||||
**Features:**
|
||||
- View personal keywords section (muted/disabled with upgrade prompt)
|
||||
- View API keys section (muted/disabled)
|
||||
- Access to upgrade flow
|
||||
- Search still unlimited (public keywords)
|
||||
|
||||
**State:** "You have 0 personal keywords. Upgrade to Personal to create unlimited."
|
||||
|
||||
#### Logged Users (Personal Tier)
|
||||
**Purpose:** Full keyword management hub
|
||||
|
||||
**Features:**
|
||||
- **Keyword Management:**
|
||||
- Table view: emoji | your keywords | language | actions (edit/delete)
|
||||
- Add new keyword: select emoji → enter keyword(s) → set language → save
|
||||
- Bulk import/export (JSON)
|
||||
- **API Key Management:**
|
||||
- Generate new keys (dew_abc123...)
|
||||
- Revoke keys
|
||||
- Copy to clipboard (for extension login)
|
||||
- View usage stats per key (future)
|
||||
- **Billing Dashboard:**
|
||||
- Current plan status
|
||||
- Payment history table (date, amount, status, invoice)
|
||||
- Upgrade/downgrade/cancel buttons
|
||||
- Next billing date
|
||||
|
||||
**Navigation:**
|
||||
```
|
||||
Dashboard
|
||||
├─ Search (always visible)
|
||||
├─ My Keywords
|
||||
├─ API Keys
|
||||
└─ Billing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Chrome Extension
|
||||
|
||||
**Purpose:** Viral growth engine + seamless Personal upsell
|
||||
|
||||
#### Free Features (Unlimited, No Limits)
|
||||
- Search public emoji keywords (EN/ID + semantic)
|
||||
- Skintone variations
|
||||
- Copy to clipboard
|
||||
- **Auto-Insert** (click emoji → inserts at cursor position)
|
||||
- **Automatic mode** (detect input → insert; else copy)
|
||||
- No daily/hourly limits
|
||||
- No login required for public search
|
||||
|
||||
#### Personal Features (Requires Login)
|
||||
- Search blends **private + public** keywords
|
||||
- Personal keywords auto-sync from dashboard
|
||||
- API key authentication (stored securely in chrome.storage)
|
||||
|
||||
#### Auth Flow
|
||||
1. User clicks "Link Account" in extension settings
|
||||
2. Login popup (email/password) → POST /v1/user/login
|
||||
3. Extension receives + stores API key
|
||||
4. All searches now include Authorization: Bearer header
|
||||
5. Results blend user's private keywords + public semantic data
|
||||
|
||||
#### Upgrade Prompt
|
||||
- Trigger: User searches term matching no public results
|
||||
- Message: "Not found in public keywords. Create 'bekicot → 🐌' in your Personal library?"
|
||||
- CTA: Opens dewemoji.com/upgrade in new tab
|
||||
|
||||
---
|
||||
|
||||
### 4.3 API (Backend)
|
||||
|
||||
**Purpose:** Single source of truth for all channels
|
||||
|
||||
#### Public Endpoints (Free, Referrer-Whitelisted)
|
||||
```
|
||||
GET /v1/emojis # Search/browse public keywords
|
||||
GET /v1/emoji/:slug # Emoji detail
|
||||
GET /v1/categories # Category list
|
||||
GET /v1/health # Health check
|
||||
```
|
||||
|
||||
**Whitelist Rules:**
|
||||
- `Origin: https://dewemoji.com`
|
||||
- `User-Agent: ...chrome-extension://elcikbedkbpkmdhkcmfnkdaacmnpdmha...`
|
||||
- `Origin: localhost | 127.0.0.1` (dev)
|
||||
|
||||
**Rate Limit:** Soft throttle at 5000 requests/hour/IP (invisible to real users, stops bombers)
|
||||
|
||||
#### User Endpoints (Authentication Required)
|
||||
```
|
||||
POST /v1/user/register # Create account
|
||||
POST /v1/user/login # Login → returns api_keys
|
||||
POST /v1/user/logout # Invalidate session
|
||||
```
|
||||
|
||||
#### Personal Tier Endpoints (API Key Required)
|
||||
```
|
||||
# Keyword CRUD
|
||||
GET /v1/keywords # List user's private keywords
|
||||
POST /v1/keywords # Create/update keyword
|
||||
DELETE /v1/keywords/:id # Delete keyword
|
||||
GET /v1/search?private=true # Search (private + public blend)
|
||||
|
||||
# API Key Management
|
||||
GET /v1/user/apikeys # List user's keys
|
||||
POST /v1/user/apikeys # Generate new key
|
||||
DELETE /v1/user/apikeys/:key # Revoke key
|
||||
```
|
||||
|
||||
**Authentication:**
|
||||
- Header: `Authorization: Bearer dew_abc123...`
|
||||
- Validation: Lookup in `user_api_keys` table → verify user_id has active Personal subscription
|
||||
|
||||
---
|
||||
|
||||
## 5. Technical Implementation
|
||||
|
||||
### 5.1 Database Schema Changes
|
||||
|
||||
#### New Tables
|
||||
|
||||
```sql
|
||||
-- Users table
|
||||
users (
|
||||
id UUID PRIMARY KEY,
|
||||
email VARCHAR UNIQUE NOT NULL,
|
||||
password_hash VARCHAR NOT NULL,
|
||||
tier ENUM('free', 'personal') DEFAULT 'free',
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)
|
||||
|
||||
-- API keys table
|
||||
user_api_keys (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id),
|
||||
key VARCHAR(64) UNIQUE NOT NULL, -- e.g., dew_abc123...
|
||||
name VARCHAR(100), -- optional label
|
||||
created_at TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP NULL
|
||||
)
|
||||
|
||||
-- Private keywords table
|
||||
user_keywords (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id),
|
||||
emoji_slug VARCHAR NOT NULL, -- references existing emoji dataset
|
||||
keyword VARCHAR(200) NOT NULL,
|
||||
lang VARCHAR(10) NOT NULL, -- ISO code or 'slang'
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
UNIQUE(user_id, emoji_slug, keyword)
|
||||
)
|
||||
|
||||
-- Subscriptions table (for billing)
|
||||
subscriptions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id),
|
||||
plan VARCHAR(20), -- 'monthly', 'annual', 'lifetime'
|
||||
status ENUM('active', 'canceled', 'expired'),
|
||||
started_at TIMESTAMP,
|
||||
expires_at TIMESTAMP NULL,
|
||||
stripe_subscription_id VARCHAR NULL
|
||||
)
|
||||
```
|
||||
|
||||
### 5.2 API Validation Flow
|
||||
|
||||
**For Public Endpoints:**
|
||||
```
|
||||
1. Check Origin/Referer/User-Agent
|
||||
2. If whitelisted → allow unlimited
|
||||
3. Else → require valid API key (Personal tier)
|
||||
4. Apply soft IP throttle (5000/hour)
|
||||
```
|
||||
|
||||
**For Personal Endpoints:**
|
||||
```
|
||||
1. Extract Authorization: Bearer <key>
|
||||
2. Lookup key in user_api_keys (not revoked)
|
||||
3. Lookup user_id → verify subscriptions.status = 'active'
|
||||
4. If valid → proceed
|
||||
5. Else → 401 Unauthorized
|
||||
```
|
||||
|
||||
### 5.3 Search Logic (Private + Public Blend)
|
||||
|
||||
**Endpoint:** `GET /v1/search?q=bekicot&private=true`
|
||||
|
||||
**Algorithm:**
|
||||
```
|
||||
1. If private=true and valid API key:
|
||||
a. Query user_keywords WHERE user_id=X AND keyword LIKE '%bekicot%'
|
||||
b. Query public emojis.json WHERE keywords LIKE '%bekicot%'
|
||||
c. Merge results (private first, then public)
|
||||
d. Deduplicate by emoji_slug
|
||||
|
||||
2. If private=false or no API key:
|
||||
a. Query public emojis.json only
|
||||
|
||||
3. Return unified response
|
||||
```
|
||||
|
||||
### 5.4 API Key Generation
|
||||
|
||||
**Format:** `dew_{random_32_chars}`
|
||||
|
||||
**Example:** `dew_k7j3m9q2n8p4r6s1t5v8w0x2y4z7a9c1`
|
||||
|
||||
**Generation:**
|
||||
```php
|
||||
function generateApiKey() {
|
||||
return 'dew_' . bin2hex(random_bytes(16));
|
||||
}
|
||||
```
|
||||
|
||||
**Storage:** Hashed (bcrypt) or plain (with secure storage + HTTPS)
|
||||
|
||||
---
|
||||
|
||||
## 6. Migration Strategy
|
||||
|
||||
### Phase 1: Foundation (Week 1-2)
|
||||
**Backend:**
|
||||
- [ ] Create database tables (users, user_api_keys, user_keywords, subscriptions)
|
||||
- [ ] Build auth endpoints (register, login, logout)
|
||||
- [ ] Build API key CRUD endpoints
|
||||
- [ ] Build private keyword CRUD endpoints
|
||||
- [ ] Implement referrer whitelisting logic
|
||||
- [ ] Update search endpoint to blend private + public
|
||||
|
||||
**Frontend (Website):**
|
||||
- [ ] Build registration/login UI
|
||||
- [ ] Build dashboard with tabs (Keywords, API Keys, Billing)
|
||||
- [ ] Build keyword management table (CRUD interface)
|
||||
- [ ] Build API key management UI
|
||||
- [ ] Add upgrade CTAs for free users
|
||||
|
||||
**Testing:**
|
||||
- [ ] Test auth flow
|
||||
- [ ] Test keyword CRUD
|
||||
- [ ] Test search blending (private + public)
|
||||
- [ ] Test referrer whitelisting
|
||||
|
||||
### Phase 2: Extension Integration (Week 3)
|
||||
**Extension Update:**
|
||||
- [ ] Add "Link Account" button in settings
|
||||
- [ ] Build login popup (OAuth-style or simple form)
|
||||
- [ ] Store API key in chrome.storage.sync
|
||||
- [ ] Update search to send Authorization header when logged in
|
||||
- [ ] Update UI to show "Signed in as {email}"
|
||||
- [ ] Add logout button
|
||||
- [ ] Add upgrade prompt on private keyword miss
|
||||
|
||||
**Testing:**
|
||||
- [ ] Test login flow from extension
|
||||
- [ ] Test private keyword search from extension
|
||||
- [ ] Test auto-insert with private keywords
|
||||
- [ ] Test logout/revoke flow
|
||||
|
||||
### Phase 3: Payment Integration (Week 4)
|
||||
**Stripe Setup:**
|
||||
- [ ] Create Stripe products (Monthly, Annual, Lifetime)
|
||||
- [ ] Build checkout flow (dewemoji.com/upgrade)
|
||||
- [ ] Build webhook handler (subscription created/updated/canceled)
|
||||
- [ ] Update subscriptions table on payment events
|
||||
- [ ] Build billing dashboard (payment history, cancel)
|
||||
|
||||
**Testing:**
|
||||
- [ ] Test full upgrade flow (free → Personal)
|
||||
- [ ] Test subscription renewal
|
||||
- [ ] Test cancellation
|
||||
- [ ] Test lifetime purchase
|
||||
|
||||
### Phase 4: Deprecation of Gumroad (Optional)
|
||||
- [ ] Add migration tool for existing Gumroad license holders
|
||||
- [ ] Offer 1-click conversion: paste license key → create account → auto-upgrade to Personal
|
||||
- [ ] Send email to existing users with migration instructions
|
||||
- [ ] Set sunset date for Gumroad validation (e.g., 90 days)
|
||||
|
||||
---
|
||||
|
||||
## 7. Changes Required to Current Site
|
||||
|
||||
### Homepage (dewemoji.com)
|
||||
**Current:** General emoji search + copy
|
||||
**Required Changes:**
|
||||
- [ ] Add hero section: "Find emojis in *your* language. Add personal keywords like 'bekicot' → 🐌"
|
||||
- [ ] Add CTA: "Search Free" + "Create Personal Library"
|
||||
- [ ] Add feature comparison table (Free vs Personal)
|
||||
- [ ] Update footer: Add "Pricing", "Dashboard", "API Docs"
|
||||
|
||||
### Search Page
|
||||
**Current:** Public search only
|
||||
**Required Changes:**
|
||||
- [ ] No changes for non-logged users
|
||||
- [ ] For logged Personal users: blend private keywords in results
|
||||
- [ ] Add badge on results: "Your keyword" vs "Public keyword"
|
||||
- [ ] Add "Edit keyword" quick action on personal results
|
||||
|
||||
### Emoji Detail Page
|
||||
**Current:** Shows public keywords, metadata
|
||||
**Required Changes:**
|
||||
- [ ] Add "Add to my keywords" button (Personal users only)
|
||||
- [ ] Quick add modal: "What do you call this? → [input] → Save"
|
||||
- [ ] Show user's private keywords for this emoji (if any)
|
||||
- [ ] Remove voting/public submission UI (deprecated)
|
||||
|
||||
### New Pages to Build
|
||||
|
||||
#### `/register`
|
||||
- Email + password form
|
||||
- "Continue with Google" (optional, future)
|
||||
- Terms acceptance
|
||||
- Auto-login after registration
|
||||
|
||||
#### `/login`
|
||||
- Email + password
|
||||
- "Forgot password" link
|
||||
- Redirect to dashboard after login
|
||||
|
||||
#### `/dashboard`
|
||||
**Tabs:**
|
||||
1. **My Keywords** (default)
|
||||
- Table: Emoji | Keywords | Language | Actions
|
||||
- Add button → modal (search emoji → add keyword)
|
||||
- Bulk import/export buttons
|
||||
|
||||
2. **API Keys**
|
||||
- List of keys with names + last used
|
||||
- Generate button → creates new key
|
||||
- Copy button + revoke button per key
|
||||
|
||||
3. **Billing**
|
||||
- Current plan badge
|
||||
- Next billing date
|
||||
- Payment history table
|
||||
- Upgrade/downgrade/cancel buttons
|
||||
|
||||
#### `/upgrade` or `/pricing`
|
||||
- Feature comparison table (Free vs Personal)
|
||||
- Pricing cards (Monthly $4.99 | Annual $49 | Lifetime $99)
|
||||
- Stripe checkout integration
|
||||
- FAQ section
|
||||
|
||||
---
|
||||
|
||||
## 8. Marketing & Positioning
|
||||
|
||||
### Target Audiences
|
||||
|
||||
**Primary:**
|
||||
1. **Multilingual communicators** (Indonesian, Korean, Japanese, etc.)
|
||||
- Problem: "Emoji pickers don't understand my language"
|
||||
- Hook: "Search in bahasa Indonesia: 'bekicot' → 🐌"
|
||||
|
||||
2. **Heavy messengers** (WhatsApp, Discord, Twitter power users)
|
||||
- Problem: "I use emojis 50+ times/day, default pickers suck"
|
||||
- Hook: "Auto-insert emojis anywhere, instantly"
|
||||
|
||||
3. **Content creators & social media managers**
|
||||
- Problem: "Need specific emojis fast, can't waste time scrolling"
|
||||
- Hook: "Your custom emoji library, synced everywhere"
|
||||
|
||||
**Secondary:**
|
||||
1. Developers needing semantic emoji API
|
||||
2. Writers using emojis in documentation
|
||||
3. Non-Latin language communities (Korean, Japanese, Russian)
|
||||
|
||||
### Positioning Statements
|
||||
|
||||
**For Indonesian users:**
|
||||
*"Dewemoji adalah library emoji pribadi yang ngerti bahasa lo—dari 'bekicot' sampe slang gaul, semua bisa jadi shortcut emoji lo."*
|
||||
|
||||
**For global users:**
|
||||
*"Your personal emoji dictionary that speaks your language—sync your custom keywords across all your devices."*
|
||||
|
||||
**For Chrome Web Store:**
|
||||
*"Emoji search that understands you. Add personal keywords like 'snail' or 'bekicot'—works anywhere, syncs everywhere."*
|
||||
|
||||
### Growth Channels
|
||||
|
||||
1. **Chrome Web Store**
|
||||
- Current: Already live (https://chromewebstore.google.com/detail/dewemoji...)
|
||||
- Action: Update description with Personal tier benefits
|
||||
- Strategy: Drive reviews via in-extension prompt ("Enjoying Dewemoji? Rate us!")
|
||||
|
||||
2. **Indonesian tech communities**
|
||||
- Reddit: r/indonesia, r/indonesian
|
||||
- Kaskus, Discord servers (DevID, IndieHackers Indonesia)
|
||||
- Messaging: "Finally, emoji search yang ngerti bahasa kita"
|
||||
|
||||
3. **Product Hunt launch**
|
||||
- Position: "Personal emoji library with multilingual keyword search"
|
||||
- Hook: "Stop scrolling through emoji pickers—search in your language"
|
||||
|
||||
4. **Twitter/X shares**
|
||||
- User screenshots: "Look, I can search 'bekicot' now!"
|
||||
- Developer shares: "Semantic emoji API that understands Indonesian slang"
|
||||
|
||||
5. **SEO (organic)**
|
||||
- Target: "emoji search [language]", "emoji picker indonesia", "find emoji by keyword"
|
||||
- Content: Blog posts on emoji localization, multilingual search
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
### Growth Metrics
|
||||
- **Extension installs/month:** Target 1,000 → 10,000 in 6 months
|
||||
- **Website MAU:** Target 5,000 → 50,000 in 6 months
|
||||
- **Free → Personal conversion:** Target 2-5% (industry standard)
|
||||
|
||||
### Revenue Metrics
|
||||
- **MRR (Monthly Recurring Revenue):** Target $500 → $5,000 in 6 months
|
||||
- **Annual plan adoption:** Target 30% of paid users
|
||||
- **Lifetime purchases:** Target 10% of paid users
|
||||
|
||||
### Usage Metrics
|
||||
- **Avg searches/user/day:** Track engagement depth
|
||||
- **Private keywords created/user:** Measure feature adoption
|
||||
- **API key active usage:** Track cross-device sync adoption
|
||||
|
||||
### Quality Metrics
|
||||
- **Extension rating:** Maintain >4.5 stars
|
||||
- **User retention (30-day):** Target >40%
|
||||
- **Churn rate:** Target <10%/month for Personal tier
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & Mitigation
|
||||
|
||||
### Risk 1: Low conversion (free → Personal)
|
||||
**Mitigation:**
|
||||
- Make free tier extremely generous (unlimited searches, all features except private)
|
||||
- Add in-context upgrade prompts ("Not found. Create this keyword in Personal?")
|
||||
- Offer 7-day free trial on Personal tier
|
||||
### Upgrade triggers
|
||||
|
||||
### Risk 2: API abuse (free tier bombing)
|
||||
**Mitigation:**
|
||||
- Referrer whitelisting (already planned)
|
||||
- Soft IP throttle (5000/hour)
|
||||
- Monitor usage patterns; tighten if needed
|
||||
- Search miss prompts
|
||||
- Locked "Your Keywords" section for free users
|
||||
- Extension contextual prompts
|
||||
|
||||
### Risk 3: Low awareness ("Who needs emoji search?")
|
||||
**Mitigation:**
|
||||
- Target pain-aware users (multilingual, heavy messengers)
|
||||
- Leverage Chrome Web Store (existing 237M Chrome users search "emoji")
|
||||
- Create viral hooks (screenshot-worthy Indonesian keyword searches)
|
||||
|
||||
### Risk 4: Payment processor issues (Indonesia)
|
||||
**Mitigation:**
|
||||
- Start with Stripe (global, easiest)
|
||||
- Add Midtrans/Xendit for local payment methods (bank transfer, e-wallet)
|
||||
- Offer annual/lifetime for users without recurring billing access
|
||||
## Platform responsibilities
|
||||
|
||||
---
|
||||
|
||||
## 11. Next Steps (Immediate Actions)
|
||||
### Website
|
||||
|
||||
### Week 1: Backend Foundation
|
||||
1. Design & implement database schema
|
||||
2. Build auth system (register, login, JWT/session)
|
||||
3. Build API key generation/validation
|
||||
4. Build private keyword CRUD endpoints
|
||||
5. Update search endpoint to blend private + public
|
||||
|
||||
### Week 2: Frontend Dashboard
|
||||
1. Build registration/login pages
|
||||
2. Build dashboard layout (tabs: Keywords, API Keys, Billing)
|
||||
3. Build keyword management table + CRUD UI
|
||||
4. Build API key management UI
|
||||
5. Add upgrade CTAs throughout free tier
|
||||
|
||||
### Week 3: Extension Integration
|
||||
1. Add "Link Account" in extension settings
|
||||
2. Build login popup for extension
|
||||
3. Store API key securely (chrome.storage)
|
||||
4. Update search to send auth header when logged in
|
||||
5. Add upgrade prompt on private keyword miss
|
||||
|
||||
### Week 4: Payment & Launch
|
||||
1. Set up Stripe products (Monthly, Annual, Lifetime)
|
||||
2. Build checkout flow + webhook handler
|
||||
3. Build billing dashboard
|
||||
4. Test full user journey (register → add keywords → login in extension → search)
|
||||
5. Soft launch to existing users + Indonesian tech communities
|
||||
|
||||
---
|
||||
|
||||
## 12. Appendix: Key Decisions Made
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| **Private-only keywords (no public)** | Eliminates moderation costs, AI costs, legal risks, complexity |
|
||||
| **Free unlimited public search** | Drives viral growth; users fall in love before paywall |
|
||||
| **Auto-Insert free (not paid)** | Core viral hook; competitors lack this, users share screenshots |
|
||||
| **Tier renamed: Pro → Personal** | "Personal" clarifies it's private keywords, not business tools |
|
||||
| **Self-generated API keys** | Flexibility with any payment provider (Stripe, Paddle, local) |
|
||||
| **Referrer whitelisting for free** | Protects API from abuse without limiting real users |
|
||||
| **No search count limits** | Emoji tools die with limits; unlimited = frictionless growth |
|
||||
| **Payment: $4.99/mo or $49/yr** | Coffee price for individuals; low friction, high volume model |
|
||||
|
||||
---
|
||||
|
||||
## 13. Questions for Future Consideration
|
||||
|
||||
1. **Team libraries:** Should Personal tier allow shared keyword libraries (e.g., family/team)?
|
||||
2. **Mobile apps:** Native iOS/Android apps or continue web + extension focus?
|
||||
3. **API marketplace:** Offer public API access for developers (separate paid tier)?
|
||||
4. **Localization:** Add more default language packs (Korean, Japanese, Spanish)?
|
||||
5. **AI assistance:** "Suggest keywords for this emoji based on my usage patterns"?
|
||||
|
||||
---
|
||||
|
||||
**End of Brief**
|
||||
|
||||
This document serves as the north star for Dewemoji's development through 2026. All implementation decisions should align with the principle: **"Free discovery → Paid personalization"** and the vision of enabling personal, multilingual emoji expression for everyone.
|
||||
- discovery pages
|
||||
- emoji detail
|
||||
- pricing/upgrade
|
||||
- user dashboard (keywords, API keys, billing)
|
||||
|
||||
### Extension
|
||||
|
||||
- free discovery remains strong
|
||||
- account linking for personal sync
|
||||
- blend private + public results when authenticated
|
||||
|
||||
### API
|
||||
|
||||
- stable public contract for search/category/detail
|
||||
- authenticated personal keyword endpoints
|
||||
- clear throttling and abuse controls
|
||||
|
||||
## Architecture priorities
|
||||
|
||||
1. Move from license-centric model toward account + subscription + API keys.
|
||||
2. Keep legacy compatibility while migration is active.
|
||||
3. Preserve cache-first behavior (`app/data/emojis.json`) for reliable performance.
|
||||
4. Keep operational observability for billing/webhooks/usage.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase A - Foundation
|
||||
|
||||
- user auth foundations
|
||||
- private keyword model
|
||||
- API key lifecycle
|
||||
- public endpoint guard/throttle hardening
|
||||
|
||||
### Phase B - Dashboard
|
||||
|
||||
- my keywords CRUD
|
||||
- API key management
|
||||
- billing state view
|
||||
- role-aware dashboard shell
|
||||
|
||||
### Phase C - Extension sync
|
||||
|
||||
- link account
|
||||
- send auth key/header
|
||||
- show private result badges and edit actions
|
||||
|
||||
### Phase D - Billing completion
|
||||
|
||||
- provider webhooks with idempotency
|
||||
- accurate subscription status transitions
|
||||
- admin controls for pricing and subscription operations
|
||||
|
||||
## Community/contribution decision
|
||||
|
||||
Current active direction is **private-first personalization**.
|
||||
|
||||
- Public community contribution/voting is not in the immediate build scope.
|
||||
- If reintroduced later, it should be optional, moderated, and separated from private keyword ownership.
|
||||
|
||||
## Admin priorities
|
||||
|
||||
1. subscription and payment visibility
|
||||
2. webhook replay and diagnostics
|
||||
3. pricing controls + change logging
|
||||
4. safety controls and audit logs
|
||||
|
||||
## Success signals
|
||||
|
||||
1. private keyword adoption per Personal user
|
||||
2. search success lift from private keywords
|
||||
3. free -> Personal conversion rate
|
||||
4. extension retention and sync reliability
|
||||
|
||||
## Risks to monitor
|
||||
|
||||
1. abuse on public endpoints -> enforce edge throttling + allowlists
|
||||
2. billing webhook drift -> queue + idempotency + replay tooling
|
||||
3. migration confusion between legacy licenses and Personal model -> explicit migration messaging
|
||||
|
||||
## Out of scope for now
|
||||
|
||||
- broad public community moderation/voting systems
|
||||
- heavy AI moderation pipelines for public contributions
|
||||
- major replatform that breaks current API contracts
|
||||
|
||||
@@ -1,873 +0,0 @@
|
||||
# Dewemoji User Flow & UX Brief
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** February 8, 2026
|
||||
**Purpose:** Define seamless user experience across visitor → logged → paid states
|
||||
**Related Doc:** dewemoji-direction-2026.md
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Dewemoji implements a **hybrid quick-add flow** where Personal users can add keywords instantly from emoji detail pages (80% of use cases) while maintaining full CRUD power in the dashboard (20% management). This creates a frictionless progression: discovery → personalization → habit, without disrupting the free user experience.
|
||||
|
||||
**Core UX Principle:** *"Add keywords where you discover emojis, manage them where you organize."*
|
||||
|
||||
---
|
||||
|
||||
## 1. User State Definitions
|
||||
|
||||
### 1.1 Visitor (Non-Logged)
|
||||
**Who:** Anyone landing on dewemoji.com or using Chrome extension (no account)
|
||||
**Intent:** Quick emoji search and usage
|
||||
**Experience:** Zero friction, no login walls
|
||||
|
||||
### 1.2 Free User (Logged, No Subscription)
|
||||
**Who:** Registered account, free tier
|
||||
**Intent:** Exploring Personal features, considering upgrade
|
||||
**Experience:** Full free features + gentle upgrade hints
|
||||
|
||||
### 1.3 Personal User (Logged, Paid)
|
||||
**Who:** Active Personal subscription ($4.99/mo, $49/yr, or $99 lifetime)
|
||||
**Intent:** Building and syncing personal keyword library
|
||||
**Experience:** Full personalization power across all touchpoints
|
||||
|
||||
---
|
||||
|
||||
## 2. Primary User Flows
|
||||
|
||||
### 2.1 Discovery Flow (All Users)
|
||||
|
||||
```
|
||||
User lands on dewemoji.com
|
||||
↓
|
||||
Search bar: "bekicot"
|
||||
↓
|
||||
Results page: 🐌 snail (public keywords: "snail, escargot")
|
||||
↓
|
||||
Click emoji → Detail page
|
||||
↓
|
||||
[State-dependent experience - see Section 3]
|
||||
```
|
||||
|
||||
**Key Insight:** All users start here. Free discovery hooks them; Personal lets them own it.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Personalization Flow (Personal Users)
|
||||
|
||||
#### Primary: Quick Add from Detail Page (80% Usage)
|
||||
|
||||
```
|
||||
Personal user searches "snail"
|
||||
↓
|
||||
Detail page shows:
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🐌 Snail │
|
||||
│ │
|
||||
│ Public Keywords: snail, escargot │
|
||||
│ │
|
||||
│ Your Keywords: (none yet) │
|
||||
│ [+ Add Your Keyword] │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
User clicks [+ Add Your Keyword]
|
||||
↓
|
||||
Inline modal appears:
|
||||
┌─────────────────────────────────────┐
|
||||
│ What do you call this emoji? │
|
||||
│ ┌─────────────────────────────────┐│
|
||||
│ │ bekicot, siput ││
|
||||
│ └─────────────────────────────────┘│
|
||||
│ Language: [Indonesian ▾] │
|
||||
│ [Cancel] [Save Keyword] │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
Saves instantly → Toast: "Added 'bekicot' to your library ✓"
|
||||
↓
|
||||
Detail page now shows:
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🐌 Snail │
|
||||
│ │
|
||||
│ Public Keywords: snail, escargot │
|
||||
│ │
|
||||
│ Your Keywords: │
|
||||
│ • bekicot (ID) [edit] [×] │
|
||||
│ • siput (ID) [edit] [×] │
|
||||
│ [+ Add Another] │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
Next search: "bekicot" → 🐌 appears in results!
|
||||
```
|
||||
|
||||
**Why This Flow:**
|
||||
- **Contextual:** User is *already looking at* the emoji they want to name
|
||||
- **Fast:** 2 clicks (add button → save), ~5 seconds total
|
||||
- **Immediate gratification:** See change instantly in searches
|
||||
- **Low friction:** No navigation away from discovery flow
|
||||
|
||||
---
|
||||
|
||||
#### Secondary: Dashboard Management (20% Usage)
|
||||
|
||||
**When Used:**
|
||||
- Bulk editing/organizing keywords
|
||||
- Reviewing all keywords at once
|
||||
- Exporting/importing keyword libraries
|
||||
- Fixing typos across multiple entries
|
||||
|
||||
```
|
||||
User navigates to Dashboard → My Keywords tab
|
||||
↓
|
||||
Table view:
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Emoji | Your Keywords | Lang | Actions │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ 🐌 | bekicot, siput | ID | [Edit] [Delete] │
|
||||
│ 😊 | senyum, happy | ID | [Edit] [Delete] │
|
||||
│ 🔥 | api, fire, lit | ID | [Edit] [Delete] │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
[+ Add Keyword] [Import JSON] [Export JSON] [Search/Filter]
|
||||
↓
|
||||
User clicks [+ Add Keyword]
|
||||
↓
|
||||
Modal: Search emoji picker → Select 🎉 → Add keywords
|
||||
↓
|
||||
Saves to table → Auto-sync to extension
|
||||
```
|
||||
|
||||
**Why Dashboard Exists:**
|
||||
- Power users want bulk operations
|
||||
- Easy to review/audit entire library
|
||||
- Import/export for backup
|
||||
- Edit mistakes without finding emoji again
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Upgrade Flow (Free → Personal)
|
||||
|
||||
#### Trigger Points
|
||||
|
||||
**1. On Detail Page (Soft Prompt)**
|
||||
```
|
||||
Free user on emoji detail page sees:
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🐌 Snail │
|
||||
│ │
|
||||
│ Public Keywords: snail, escargot │
|
||||
│ │
|
||||
│ 💎 Want to add your own keywords? │
|
||||
│ like 'bekicot' or 'siput'? │
|
||||
│ [Upgrade to Personal →] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**2. On Search Miss (Strong Prompt)**
|
||||
```
|
||||
User searches "bekicot" → No results
|
||||
↓
|
||||
Results page shows:
|
||||
┌─────────────────────────────────────┐
|
||||
│ No results for "bekicot" │
|
||||
│ │
|
||||
│ 💡 With Personal, you can create │
|
||||
│ "bekicot → 🐌" and sync it │
|
||||
│ everywhere. │
|
||||
│ [Try Personal Free for 7 Days] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**3. From Extension (Contextual)**
|
||||
```
|
||||
Extension user searches "bekicot" → Not found
|
||||
↓
|
||||
Extension shows inline banner:
|
||||
"Add 'bekicot' to your library with Personal"
|
||||
[Upgrade] button → Opens dewemoji.com/upgrade
|
||||
```
|
||||
|
||||
**4. From Dashboard (Clear Path)**
|
||||
```
|
||||
Free user clicks "My Keywords" tab
|
||||
↓
|
||||
Shows empty state:
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📚 Your Personal Keyword Library │
|
||||
│ │
|
||||
│ You have 0 keywords. │
|
||||
│ │
|
||||
│ Upgrade to Personal to: │
|
||||
│ ✓ Add unlimited keywords │
|
||||
│ ✓ Search in your language │
|
||||
│ ✓ Sync across all devices │
|
||||
│ │
|
||||
│ [Start 7-Day Free Trial] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. State-Dependent UI Elements
|
||||
|
||||
### 3.1 Emoji Detail Page
|
||||
|
||||
#### Visitor (Non-Logged)
|
||||
```html
|
||||
<!-- Public info only -->
|
||||
<div class="emoji-detail">
|
||||
<h1>🐌 Snail</h1>
|
||||
<section class="public-keywords">
|
||||
<h3>Keywords</h3>
|
||||
<ul>
|
||||
<li>snail</li>
|
||||
<li>escargot</li>
|
||||
<li>slow</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="cta-banner">
|
||||
<p>💡 Want to search in your language?</p>
|
||||
<button>Sign Up Free</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Free User (Logged)
|
||||
```html
|
||||
<!-- Public info + muted personal section -->
|
||||
<div class="emoji-detail">
|
||||
<h1>🐌 Snail</h1>
|
||||
|
||||
<section class="public-keywords">
|
||||
<h3>Keywords</h3>
|
||||
<ul><li>snail</li><li>escargot</li></ul>
|
||||
</section>
|
||||
|
||||
<section class="user-keywords muted">
|
||||
<h3>Your Keywords</h3>
|
||||
<div class="upgrade-overlay">
|
||||
<p>💎 Add personal keywords like 'bekicot'</p>
|
||||
<button class="upgrade-btn">Upgrade to Personal</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Personal User (Logged + Paid)
|
||||
```html
|
||||
<!-- Full personalization active -->
|
||||
<div class="emoji-detail">
|
||||
<h1>🐌 Snail</h1>
|
||||
|
||||
<section class="public-keywords">
|
||||
<h3>Public Keywords</h3>
|
||||
<ul><li>snail</li><li>escargot</li><li>slow</li></ul>
|
||||
</section>
|
||||
|
||||
<section class="user-keywords active">
|
||||
<h3>Your Keywords</h3>
|
||||
<ul class="keyword-list">
|
||||
<li>
|
||||
<span class="keyword">bekicot</span>
|
||||
<span class="lang-tag">ID</span>
|
||||
<button class="edit-btn">edit</button>
|
||||
<button class="delete-btn">×</button>
|
||||
</li>
|
||||
<li>
|
||||
<span class="keyword">siput</span>
|
||||
<span class="lang-tag">ID</span>
|
||||
<button class="edit-btn">edit</button>
|
||||
<button class="delete-btn">×</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="add-keyword-btn">+ Add Keyword</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Add Keyword Modal (hidden until clicked) -->
|
||||
<dialog id="add-keyword-modal">
|
||||
<h3>What do you call this emoji?</h3>
|
||||
<input type="text" placeholder="Enter keywords (comma-separated)" />
|
||||
<select name="language">
|
||||
<option value="en">English</option>
|
||||
<option value="id">Indonesian</option>
|
||||
<option value="ko">Korean</option>
|
||||
<option value="ja">Japanese</option>
|
||||
</select>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel">Cancel</button>
|
||||
<button class="save primary">Save Keyword</button>
|
||||
</div>
|
||||
</dialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Search Results Page
|
||||
|
||||
#### All Users (Public Results)
|
||||
```html
|
||||
<div class="search-results">
|
||||
<div class="emoji-card">
|
||||
<span class="emoji">🐌</span>
|
||||
<span class="name">Snail</span>
|
||||
<span class="matched-keyword">snail</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Personal User (Blended Results)
|
||||
```html
|
||||
<div class="search-results">
|
||||
<!-- Private keyword match (appears first) -->
|
||||
<div class="emoji-card user-match">
|
||||
<span class="emoji">🐌</span>
|
||||
<span class="name">Snail</span>
|
||||
<span class="matched-keyword">
|
||||
bekicot
|
||||
<span class="badge personal">Your keyword</span>
|
||||
</span>
|
||||
<button class="quick-edit">Edit</button>
|
||||
</div>
|
||||
|
||||
<!-- Public keyword matches (below) -->
|
||||
<div class="emoji-card">
|
||||
<span class="emoji">🐌</span>
|
||||
<span class="name">Snail</span>
|
||||
<span class="matched-keyword">snail</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Dashboard (Personal Users Only)
|
||||
|
||||
```html
|
||||
<div class="dashboard">
|
||||
<nav class="tabs">
|
||||
<button class="active">My Keywords</button>
|
||||
<button>API Keys</button>
|
||||
<button>Billing</button>
|
||||
</nav>
|
||||
|
||||
<!-- My Keywords Tab -->
|
||||
<div class="tab-content" id="keywords">
|
||||
<div class="toolbar">
|
||||
<button class="primary">+ Add Keyword</button>
|
||||
<button>Import JSON</button>
|
||||
<button>Export JSON</button>
|
||||
<input type="search" placeholder="Search your keywords..." />
|
||||
</div>
|
||||
|
||||
<table class="keyword-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Emoji</th>
|
||||
<th>Your Keywords</th>
|
||||
<th>Language</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>🐌</td>
|
||||
<td>bekicot, siput</td>
|
||||
<td><span class="lang-tag">ID</span></td>
|
||||
<td>
|
||||
<button class="edit">Edit</button>
|
||||
<button class="delete">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- More rows... -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Endpoints for UX Flows
|
||||
|
||||
### 4.1 Detail Page Quick Add
|
||||
|
||||
**Fetch user's keywords for specific emoji:**
|
||||
```
|
||||
GET /v1/emoji/:slug?include_user_keywords=true
|
||||
Authorization: Bearer dew_abc123...
|
||||
|
||||
Response:
|
||||
{
|
||||
"emoji": {...},
|
||||
"public_keywords": ["snail", "escargot"],
|
||||
"user_keywords": [
|
||||
{"id": "uk_1", "keyword": "bekicot", "lang": "id"},
|
||||
{"id": "uk_2", "keyword": "siput", "lang": "id"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Add keyword from detail page:**
|
||||
```
|
||||
POST /v1/keywords/quick
|
||||
Authorization: Bearer dew_abc123...
|
||||
|
||||
Body:
|
||||
{
|
||||
"emoji_slug": "snail",
|
||||
"keywords": ["bekicot", "siput"],
|
||||
"lang": "id"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"added": ["bekicot", "siput"],
|
||||
"emoji_slug": "snail"
|
||||
}
|
||||
```
|
||||
|
||||
**Edit single keyword:**
|
||||
```
|
||||
PATCH /v1/keywords/:id
|
||||
Authorization: Bearer dew_abc123...
|
||||
|
||||
Body:
|
||||
{
|
||||
"keyword": "bekicot kecil"
|
||||
}
|
||||
```
|
||||
|
||||
**Delete keyword:**
|
||||
```
|
||||
DELETE /v1/keywords/:id
|
||||
Authorization: Bearer dew_abc123...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Dashboard Bulk Operations
|
||||
|
||||
**List all user keywords:**
|
||||
```
|
||||
GET /v1/keywords?page=1&limit=50
|
||||
Authorization: Bearer dew_abc123...
|
||||
|
||||
Response:
|
||||
{
|
||||
"keywords": [
|
||||
{
|
||||
"id": "uk_1",
|
||||
"emoji_slug": "snail",
|
||||
"emoji": "🐌",
|
||||
"keyword": "bekicot",
|
||||
"lang": "id",
|
||||
"created_at": "2026-02-08T10:30:00Z"
|
||||
}
|
||||
// ... more
|
||||
],
|
||||
"total": 47,
|
||||
"page": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Bulk import:**
|
||||
```
|
||||
POST /v1/keywords/import
|
||||
Authorization: Bearer dew_abc123...
|
||||
|
||||
Body:
|
||||
{
|
||||
"keywords": [
|
||||
{"emoji_slug": "snail", "keywords": ["bekicot"], "lang": "id"},
|
||||
{"emoji_slug": "fire", "keywords": ["api"], "lang": "id"}
|
||||
]
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"imported": 2,
|
||||
"skipped": 0,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
**Export:**
|
||||
```
|
||||
GET /v1/keywords/export?format=json
|
||||
Authorization: Bearer dew_abc123...
|
||||
|
||||
Response: (Download JSON file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend Implementation Specs
|
||||
|
||||
### 5.1 Emoji Detail Page Changes
|
||||
|
||||
**File:** `/pages/emoji/[slug].js` (or equivalent)
|
||||
|
||||
**Required Elements:**
|
||||
|
||||
1. **User keywords section** (Personal only)
|
||||
- Fetch on page load: `GET /v1/emoji/:slug?include_user_keywords=true`
|
||||
- Show list of user's keywords with edit/delete buttons
|
||||
- Show "Add Keyword" button
|
||||
|
||||
2. **Add keyword modal**
|
||||
- Input: comma-separated keywords
|
||||
- Dropdown: language selector (EN, ID, KO, JA, etc.)
|
||||
- Save button → POST `/v1/keywords/quick`
|
||||
- Success → update UI without page reload
|
||||
|
||||
3. **Inline edit**
|
||||
- Click "edit" → input becomes editable
|
||||
- Save → PATCH `/v1/keywords/:id`
|
||||
- Cancel → revert
|
||||
|
||||
4. **Inline delete**
|
||||
- Click "×" → confirm modal
|
||||
- Yes → DELETE `/v1/keywords/:id` → remove from UI
|
||||
|
||||
**State Management:**
|
||||
```javascript
|
||||
const [userKeywords, setUserKeywords] = useState([]);
|
||||
const [isPersonal, setIsPersonal] = useState(false);
|
||||
|
||||
// On mount
|
||||
useEffect(() => {
|
||||
if (user?.tier === 'personal') {
|
||||
fetchUserKeywords(emojiSlug);
|
||||
}
|
||||
}, [emojiSlug, user]);
|
||||
|
||||
// Add keyword
|
||||
const handleAddKeyword = async (keywords, lang) => {
|
||||
const result = await api.post('/keywords/quick', {
|
||||
emoji_slug: emojiSlug,
|
||||
keywords: keywords.split(',').map(k => k.trim()),
|
||||
lang
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
fetchUserKeywords(emojiSlug); // Refresh list
|
||||
toast.success('Keywords added!');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Dashboard Implementation
|
||||
|
||||
**File:** `/pages/dashboard.js`
|
||||
|
||||
**Tabs:**
|
||||
1. My Keywords (default)
|
||||
2. API Keys
|
||||
3. Billing
|
||||
|
||||
**My Keywords Tab Components:**
|
||||
|
||||
```javascript
|
||||
// Main table component
|
||||
<KeywordTable
|
||||
keywords={keywords}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
|
||||
// Toolbar actions
|
||||
<Toolbar>
|
||||
<AddKeywordButton onClick={openModal} />
|
||||
<ImportButton />
|
||||
<ExportButton />
|
||||
<SearchInput onChange={handleSearch} />
|
||||
</Toolbar>
|
||||
|
||||
// Add keyword modal (with emoji picker)
|
||||
<AddKeywordModal
|
||||
onSave={handleAddKeyword}
|
||||
onCancel={closeModal}
|
||||
/>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Pagination (50 per page)
|
||||
- Search/filter by keyword or emoji
|
||||
- Bulk select + delete
|
||||
- Sort by: date added, emoji name, language
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Upgrade Flow Pages
|
||||
|
||||
**File:** `/pages/upgrade.js` or `/pages/pricing.js`
|
||||
|
||||
**Components:**
|
||||
|
||||
1. **Feature comparison table**
|
||||
```html
|
||||
<table class="pricing-comparison">
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th>Free</th>
|
||||
<th>Personal</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Public emoji search</td>
|
||||
<td>✅ Unlimited</td>
|
||||
<td>✅ Unlimited</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Private keywords</td>
|
||||
<td>❌</td>
|
||||
<td>✅ Unlimited</td>
|
||||
</tr>
|
||||
<!-- ... -->
|
||||
</table>
|
||||
```
|
||||
|
||||
2. **Pricing cards**
|
||||
```html
|
||||
<div class="pricing-cards">
|
||||
<div class="card">
|
||||
<h3>Monthly</h3>
|
||||
<p class="price">$4.99/mo</p>
|
||||
<button>Subscribe</button>
|
||||
</div>
|
||||
<div class="card recommended">
|
||||
<span class="badge">Best Value</span>
|
||||
<h3>Annual</h3>
|
||||
<p class="price">$49/year</p>
|
||||
<p class="savings">Save 17%</p>
|
||||
<button>Subscribe</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Lifetime</h3>
|
||||
<p class="price">$99 once</p>
|
||||
<button>Buy Now</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Stripe checkout integration**
|
||||
```javascript
|
||||
const handleCheckout = async (plan) => {
|
||||
const session = await api.post('/stripe/checkout', {
|
||||
plan, // 'monthly' | 'annual' | 'lifetime'
|
||||
success_url: `${baseUrl}/dashboard?upgraded=true`,
|
||||
cancel_url: `${baseUrl}/upgrade`
|
||||
});
|
||||
|
||||
window.location.href = session.url;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UX Copy & Messaging
|
||||
|
||||
### 6.1 Upgrade Prompts
|
||||
|
||||
**Soft (Detail Page):**
|
||||
> 💡 Want to search in your language? Add personal keywords like 'bekicot' for 🐌
|
||||
> [Upgrade to Personal →]
|
||||
|
||||
**Strong (Search Miss):**
|
||||
> No results for "bekicot"
|
||||
>
|
||||
> 💎 **Create your own keywords with Personal**
|
||||
> Add "bekicot → 🐌" and sync it across all devices
|
||||
> [Try Free for 7 Days]
|
||||
|
||||
**Empty State (Dashboard):**
|
||||
> 📚 **Your Personal Keyword Library**
|
||||
>
|
||||
> You have 0 keywords.
|
||||
>
|
||||
> Upgrade to Personal to:
|
||||
> ✓ Add unlimited keywords
|
||||
> ✓ Search in your language
|
||||
> ✓ Sync across all devices
|
||||
>
|
||||
> [Start 7-Day Free Trial]
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Success Messages
|
||||
|
||||
**After adding keyword:**
|
||||
> ✓ Added "bekicot" to your library
|
||||
|
||||
**After editing:**
|
||||
> ✓ Keyword updated
|
||||
|
||||
**After deleting:**
|
||||
> ✓ Keyword removed
|
||||
|
||||
**After upgrade:**
|
||||
> 🎉 Welcome to Personal! Start adding your keywords.
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Error Messages
|
||||
|
||||
**Duplicate keyword:**
|
||||
> ⚠️ You already have "bekicot" for this emoji
|
||||
|
||||
**Network error:**
|
||||
> ❌ Couldn't save. Check your connection and try again.
|
||||
|
||||
**Unauthorized:**
|
||||
> 🔒 Please log in to add personal keywords
|
||||
|
||||
---
|
||||
|
||||
## 7. Mobile Considerations
|
||||
|
||||
### 7.1 Detail Page on Mobile
|
||||
|
||||
- Add keyword button: **sticky bottom bar** (always visible)
|
||||
- Modal: **full-screen** on mobile (easier typing)
|
||||
- Keyword list: **swipeable cards** (swipe left to delete)
|
||||
|
||||
### 7.2 Dashboard on Mobile
|
||||
|
||||
- Tabs: **horizontal scroll** or bottom nav
|
||||
- Table: **card view** (stack columns vertically)
|
||||
- Actions: **swipe gestures** (edit/delete)
|
||||
|
||||
---
|
||||
|
||||
## 8. Performance & Optimization
|
||||
|
||||
### 8.1 Caching Strategy
|
||||
|
||||
**Client-side:**
|
||||
- Cache user keywords in localStorage/sessionStorage
|
||||
- Only refetch when:
|
||||
- User adds/edits/deletes
|
||||
- Page reload after 5 minutes
|
||||
|
||||
**API-side:**
|
||||
- ETag support (already implemented)
|
||||
- Cache user keywords per user_id (Redis)
|
||||
- Invalidate on mutation
|
||||
|
||||
### 8.2 Loading States
|
||||
|
||||
**Detail page:**
|
||||
```
|
||||
Loading user keywords...
|
||||
└─ Skeleton: [▯▯▯▯] [▯▯▯] [▯▯▯▯▯]
|
||||
```
|
||||
|
||||
**Dashboard:**
|
||||
```
|
||||
Loading your library...
|
||||
└─ Table skeleton (5 rows)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Analytics & Tracking
|
||||
|
||||
### Key Events to Track
|
||||
|
||||
**Discovery:**
|
||||
- `emoji_viewed` (slug, user_tier)
|
||||
- `public_search` (query, has_results)
|
||||
- `search_miss` (query) → upgrade opportunity
|
||||
|
||||
**Personalization:**
|
||||
- `keyword_added` (method: 'quick_add' | 'dashboard')
|
||||
- `keyword_edited`
|
||||
- `keyword_deleted`
|
||||
- `keywords_exported`
|
||||
|
||||
**Conversion:**
|
||||
- `upgrade_prompt_shown` (location: 'detail' | 'search' | 'dashboard')
|
||||
- `upgrade_clicked` (location)
|
||||
- `trial_started`
|
||||
- `subscription_created` (plan: 'monthly' | 'annual' | 'lifetime')
|
||||
|
||||
**Usage:**
|
||||
- `private_keyword_searched` (query, has_results)
|
||||
- `extension_synced` (keyword_count)
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing Checklist
|
||||
|
||||
### 10.1 User State Tests
|
||||
|
||||
- [ ] Visitor can search and view emoji details (no login)
|
||||
- [ ] Visitor sees upgrade CTA (non-intrusive)
|
||||
- [ ] Free user sees muted keyword section
|
||||
- [ ] Free user can't add keywords without upgrade
|
||||
- [ ] Personal user sees active keyword section
|
||||
- [ ] Personal user can add keywords from detail page
|
||||
- [ ] Personal user can edit/delete keywords inline
|
||||
|
||||
### 10.2 Flow Tests
|
||||
|
||||
- [ ] Quick add: add keyword → appears in list immediately
|
||||
- [ ] Quick add: new keyword searchable instantly
|
||||
- [ ] Dashboard: bulk add works correctly
|
||||
- [ ] Dashboard: export downloads valid JSON
|
||||
- [ ] Dashboard: import restores keywords
|
||||
- [ ] Search: private keywords appear first in results
|
||||
- [ ] Search: private keywords have "Your keyword" badge
|
||||
|
||||
### 10.3 Edge Cases
|
||||
|
||||
- [ ] Add duplicate keyword → shows error
|
||||
- [ ] Delete last keyword for emoji → removes emoji from search
|
||||
- [ ] Offline: queue mutations, sync when online
|
||||
- [ ] Rate limit: show friendly error
|
||||
- [ ] API error: show retry option
|
||||
|
||||
---
|
||||
|
||||
## 11. Implementation Priority
|
||||
|
||||
### Phase 1: Detail Page Quick Add (Week 1)
|
||||
1. Add user keywords section to detail page
|
||||
2. Build add keyword modal
|
||||
3. Implement POST /keywords/quick endpoint
|
||||
4. Add inline edit/delete
|
||||
|
||||
### Phase 2: Search Blending (Week 1)
|
||||
1. Update search to blend private + public
|
||||
2. Add "Your keyword" badge to results
|
||||
3. Show private matches first
|
||||
|
||||
### Phase 3: Dashboard (Week 2)
|
||||
1. Build My Keywords tab with table
|
||||
2. Add bulk actions (import/export)
|
||||
3. Implement search/filter
|
||||
|
||||
### Phase 4: Upgrade Flow (Week 2)
|
||||
1. Build upgrade prompts (detail, search miss, dashboard)
|
||||
2. Create pricing page
|
||||
3. Integrate Stripe checkout
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions & Future Enhancements
|
||||
|
||||
1. **Keyword suggestions:** Should we suggest keywords based on emoji name/category?
|
||||
2. **Keyboard shortcuts:** Quick add keyword with hotkey (Ctrl+K)?
|
||||
3. **Recently added:** Show "Recently added keywords" widget on dashboard?
|
||||
4. **Keyword collections:** Group keywords by category/theme?
|
||||
5. **Sharing:** Allow users to share their keyword libraries with friends?
|
||||
|
||||
---
|
||||
|
||||
**End of UX Brief**
|
||||
|
||||
This document defines the seamless user experience for Dewemoji's core value proposition: instant, contextual personalization. Implementation should prioritize the detail page quick-add flow (80% of usage) while building dashboard for power users (20%).
|
||||
@@ -1,117 +0,0 @@
|
||||
# .env Migration Checklist
|
||||
|
||||
Use this to map legacy `dewemoji-live-backend/public_html/config/env.php` into NativePHP app `app/.env`.
|
||||
|
||||
## 1) Local development (safe default)
|
||||
|
||||
```env
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://127.0.0.1:8000
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
|
||||
DEWEMOJI_BILLING_MODE=sandbox
|
||||
DEWEMOJI_LICENSE_ACCEPT_ALL=false
|
||||
DEWEMOJI_PRO_KEYS=
|
||||
|
||||
DEWEMOJI_ALLOWED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000
|
||||
DEWEMOJI_FRONTEND_HEADER=web-v1
|
||||
DEWEMOJI_FREE_DAILY_LIMIT=30
|
||||
|
||||
DEWEMOJI_GUMROAD_ENABLED=false
|
||||
DEWEMOJI_MAYAR_ENABLED=false
|
||||
```
|
||||
|
||||
## 2) Staging (real provider test)
|
||||
|
||||
```env
|
||||
APP_ENV=staging
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://staging.your-domain.com
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=dewemojiAPI_DB
|
||||
DB_USERNAME=...
|
||||
DB_PASSWORD=...
|
||||
|
||||
DEWEMOJI_BILLING_MODE=live
|
||||
DEWEMOJI_LICENSE_ACCEPT_ALL=false
|
||||
DEWEMOJI_PRO_KEYS=
|
||||
|
||||
DEWEMOJI_ALLOWED_ORIGINS=https://staging.your-domain.com,https://dewemoji.com,https://www.dewemoji.com,https://emoji.dewe.pw
|
||||
DEWEMOJI_FRONTEND_HEADER=web-v1
|
||||
DEWEMOJI_FREE_DAILY_LIMIT=30
|
||||
|
||||
DEWEMOJI_GUMROAD_ENABLED=true
|
||||
DEWEMOJI_GUMROAD_VERIFY_URL=https://api.gumroad.com/v2/licenses/verify
|
||||
DEWEMOJI_GUMROAD_PRODUCT_IDS=qfvcuT7RwcDn5Oi4KQcOBQ==,t8Kq1G5wzrd1KcYOgukpzw==
|
||||
|
||||
DEWEMOJI_MAYAR_ENABLED=true
|
||||
DEWEMOJI_MAYAR_API_BASE=https://api.mayar.id
|
||||
DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify
|
||||
DEWEMOJI_MAYAR_SECRET_KEY=...
|
||||
```
|
||||
|
||||
## 3) Production
|
||||
|
||||
```env
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://dewemoji.com
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=dewemojiAPI_DB
|
||||
DB_USERNAME=...
|
||||
DB_PASSWORD=...
|
||||
|
||||
DEWEMOJI_BILLING_MODE=live
|
||||
DEWEMOJI_LICENSE_ACCEPT_ALL=false
|
||||
DEWEMOJI_PRO_KEYS=
|
||||
|
||||
DEWEMOJI_ALLOWED_ORIGINS=https://dewemoji.com,https://www.dewemoji.com
|
||||
DEWEMOJI_FRONTEND_HEADER=web-v1
|
||||
DEWEMOJI_FREE_DAILY_LIMIT=30
|
||||
|
||||
DEWEMOJI_GUMROAD_ENABLED=true
|
||||
DEWEMOJI_GUMROAD_VERIFY_URL=https://api.gumroad.com/v2/licenses/verify
|
||||
DEWEMOJI_GUMROAD_PRODUCT_IDS=qfvcuT7RwcDn5Oi4KQcOBQ==,t8Kq1G5wzrd1KcYOgukpzw==
|
||||
|
||||
DEWEMOJI_MAYAR_ENABLED=true
|
||||
DEWEMOJI_MAYAR_API_BASE=https://api.mayar.id
|
||||
DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify
|
||||
DEWEMOJI_MAYAR_SECRET_KEY=...
|
||||
```
|
||||
|
||||
## 4) Legacy -> NativePHP key map
|
||||
|
||||
- `gateway_mode` -> `DEWEMOJI_BILLING_MODE`
|
||||
- `allowed_origins[]` -> `DEWEMOJI_ALLOWED_ORIGINS` (comma-separated)
|
||||
- `frontend_header` -> `DEWEMOJI_FRONTEND_HEADER`
|
||||
- `free_daily_limit` -> `DEWEMOJI_FREE_DAILY_LIMIT`
|
||||
- `gumroad.product_ids[]` -> `DEWEMOJI_GUMROAD_PRODUCT_IDS` (comma-separated)
|
||||
- `gumroad.verify_url` -> `DEWEMOJI_GUMROAD_VERIFY_URL`
|
||||
- `mayar.api_base` -> `DEWEMOJI_MAYAR_API_BASE`
|
||||
- `mayar.endpoint_verify` -> `DEWEMOJI_MAYAR_ENDPOINT_VERIFY`
|
||||
- `mayar.secret_key` -> `DEWEMOJI_MAYAR_SECRET_KEY`
|
||||
|
||||
## 5) Final checks
|
||||
|
||||
1. Run: `php artisan config:clear`
|
||||
2. Run: `php artisan migrate --force`
|
||||
3. Test verify endpoint with real key (Gumroad and Mayar)
|
||||
4. Confirm `/v1/license/activate` + `/v1/license/deactivate`
|
||||
5. Confirm frontend pages: `/`, `/browse`, `/support`, `/pricing`, `/api-docs`
|
||||
|
||||
## 6) Security note
|
||||
|
||||
Legacy `env.php` contains exposed secrets. Rotate all production credentials before final cutover:
|
||||
- DB password
|
||||
- Mayar key
|
||||
- Turnstile keys
|
||||
- OpenRouter key
|
||||
- Redis password
|
||||
@@ -1,93 +0,0 @@
|
||||
# Legacy API Spec (Retraced)
|
||||
|
||||
This spec is based on:
|
||||
- backend code in `../dewemoji-api`
|
||||
- extension usage in `../dewemoji-chrome-ext`
|
||||
|
||||
## 1) Implemented backend API (`dewemoji-api`)
|
||||
|
||||
### `GET /api/emojis`
|
||||
- Params supported in backend code:
|
||||
- `query` (search string)
|
||||
- `category`
|
||||
- `subcategory`
|
||||
- `page` (default `1`)
|
||||
- `limit` (default `50`, max `50`)
|
||||
- `key` (optional license key for tier detection)
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
- ETag + `Cache-Control: public, max-age=300`
|
||||
|
||||
### `GET /api/emoji/{slug}` and `GET /api/emoji?slug=...`
|
||||
- Response: single emoji object
|
||||
- Not found: `404 {"error":"not_found"}`
|
||||
- ETag + `Cache-Control: public, max-age=300`
|
||||
|
||||
### `GET /api/categories`
|
||||
- Response shape:
|
||||
```json
|
||||
{
|
||||
"Smileys & Emotion": ["face-smiling", "face-affection"],
|
||||
"People & Body": ["hand-fingers-open", "person"]
|
||||
}
|
||||
```
|
||||
- `Cache-Control: public, max-age=3600`
|
||||
|
||||
### Router-level behavior
|
||||
- CORS: `*`
|
||||
- Methods: `GET, OPTIONS`
|
||||
- Rate-limit: ~60 req/min/IP
|
||||
- Rate-limit error: `429 {"error":"rate_limited"}`
|
||||
|
||||
## 2) Extension-consumed API contract (`dewemoji-chrome-ext`)
|
||||
|
||||
Extension points to: `https://api.dewemoji.com/v1`
|
||||
|
||||
### `GET /v1/emojis`
|
||||
- Params sent by extension:
|
||||
- `q`
|
||||
- `category`
|
||||
- `subcategory`
|
||||
- `page`
|
||||
- `limit` (Free 20 / Pro 50 in client logic)
|
||||
- Expected response fields:
|
||||
- top-level: `items`, `total`
|
||||
- per-item: at least `emoji`, `name`, `category`, `subcategory`, `supports_skin_tone`
|
||||
- Header read by extension: `X-Dewemoji-Tier`
|
||||
|
||||
### `GET /v1/categories`
|
||||
Extension accepts two payload styles:
|
||||
1) object map `{ "Category": ["sub"] }`
|
||||
2) items array `{ "items": [{ "name": "Category", "subcategories": [...] }] }`
|
||||
|
||||
### `POST /v1/license/verify`
|
||||
- Body sent:
|
||||
```json
|
||||
{
|
||||
"key": "<license>",
|
||||
"account_id": "<hashed-account-id>",
|
||||
"version": "<extension-version>"
|
||||
}
|
||||
```
|
||||
- Expected minimum response:
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
or
|
||||
```json
|
||||
{ "ok": false, "error": "..." }
|
||||
```
|
||||
|
||||
## 3) Compatibility gaps to handle in rebuild
|
||||
|
||||
- Accept both `q` and `query` for search.
|
||||
- Keep `/v1/*` contract stable for extension.
|
||||
- Return `X-Dewemoji-Tier`.
|
||||
- Preserve key item fields used by extension UI and tone logic.
|
||||
@@ -1,68 +0,0 @@
|
||||
# Legacy Credentials and Config (Retraced)
|
||||
|
||||
## Source folders used
|
||||
|
||||
- `../dewemoji-api`
|
||||
- `../dewemoji-chrome-ext`
|
||||
- `../dewemoji-site`
|
||||
|
||||
## 1) Extension auth and credential flow
|
||||
|
||||
From `dewemoji-chrome-ext/panel.js`:
|
||||
- User enters license key in settings.
|
||||
- Extension verifies via `POST https://api.dewemoji.com/v1/license/verify`.
|
||||
- License key is sent as payload and reused in headers for data requests.
|
||||
|
||||
Headers used on licensed requests:
|
||||
- `Authorization: Bearer <licenseKey>`
|
||||
- `X-License-Key: <licenseKey>`
|
||||
- `X-Account-Id: <hashedAccountId>`
|
||||
- `X-Dewemoji-Frontend: ext-v1` (always sent)
|
||||
|
||||
## 2) Local/sync storage keys (extension)
|
||||
|
||||
Local storage keys observed:
|
||||
- `theme`
|
||||
- `licenseValid`
|
||||
- `licenseKey`
|
||||
- `lastLicenseCheck`
|
||||
- `actionMode`
|
||||
- `searchCache`
|
||||
- `accountId`
|
||||
- `accountLabel`
|
||||
- `profileUUID`
|
||||
- usage key pattern: `usage_YYYYMMDD`
|
||||
|
||||
Sync storage keys observed:
|
||||
- `preferredSkinTone`
|
||||
- `toneLock`
|
||||
|
||||
## 3) Backend (`dewemoji-api`) auth status
|
||||
|
||||
From `helpers/auth.php`:
|
||||
- Reads license from query `key` or bearer token.
|
||||
- Validation hooks exist (`isValidGumroad`, `isValidMayar`) but are stubs returning `false`.
|
||||
- Effective behavior in current code: fallback to free tier.
|
||||
|
||||
## 4) `dewemoji-site` credential/config status
|
||||
|
||||
- Site folder currently scaffold/empty files.
|
||||
- No active credential logic currently implemented there.
|
||||
|
||||
## 5) Hardcoded secrets check result
|
||||
|
||||
In inspected source files:
|
||||
- No committed private API secrets/tokens/passwords found.
|
||||
- License key is user-provided at runtime (extension).
|
||||
|
||||
## 6) Rebuild config recommendations
|
||||
|
||||
For new NativePHP app, add env-managed secrets for:
|
||||
- License provider API credentials
|
||||
- Internal signing/app secrets
|
||||
- Any DB credentials
|
||||
|
||||
And keep backward compatibility during migration for:
|
||||
- `Authorization` bearer auth
|
||||
- legacy `X-License-Key`
|
||||
- `X-Account-Id`
|
||||