├── bin ├── open-prod-db.sh ├── postgres-heroku.sh ├── start-dev.sh ├── open-local-db.sh ├── postgres-local.sh ├── setup ├── heroku-logs.sh ├── deploy.sh ├── run-server-jar.sh ├── make-jar.sh └── set-env-variables TEMPLATE.sh ├── dev ├── design mocks │ ├── about.png │ ├── main screen.jpg │ └── main screen map.jpg └── dev │ └── repl.clj ├── resources ├── public │ ├── washi.png │ ├── az-subtle.png │ ├── devons_map.png │ ├── kiwi-favicon.png │ ├── orange-favicon.png │ ├── meetcute-opengraph.png │ ├── oranges-tile-repeat.jpg │ ├── oranges-tile-repeat-2.jpg │ ├── signup.js │ ├── signin.js │ ├── heroku-errors │ │ └── application-error.html │ ├── meetcute.html │ ├── index.html │ ├── privacy-policy.html │ ├── terms-and-conditions.html │ └── css │ │ └── meetcute.css └── sql │ ├── schema-impersonation.sql │ ├── schema-friends.sql │ ├── schema-coordinates.sql │ ├── schema-access-tokens.sql │ ├── schema-users.sql │ ├── schema-twitter-profiles.sql │ └── schema-settings.sql ├── Small World launch materials ├── Procfile ├── src ├── ketchup │ ├── env.clj │ ├── auth.clj │ ├── notify.clj │ ├── db.clj │ └── routes.clj ├── meetcute │ ├── screens │ │ └── styles.cljc │ ├── env.clj │ ├── sms.clj │ ├── auth_test.clj │ ├── util.cljc │ └── routes.clj ├── smallworld │ ├── session.cljc │ ├── util.clj │ ├── email.clj │ ├── memoize.clj │ ├── admin.cljc │ ├── util.cljs │ ├── airtable.clj │ ├── anki_pkg.clj │ ├── coordinates.clj │ ├── mocks.cljc │ ├── frontend.cljs │ ├── decorations.cljs │ ├── user_data.cljs │ ├── clj_postgresql │ │ └── types.clj │ ├── db.clj │ ├── user_data.clj │ └── screens │ │ └── home.cljs └── sdk │ └── expo.clj ├── .vscode ├── settings.json └── extensions.json ├── .gitignore └── project.clj /bin/open-prod-db.sh: -------------------------------------------------------------------------------- 1 | heroku pg:psql postgresql-fluffy-56995 --app small-world-friends -------------------------------------------------------------------------------- /bin/postgres-heroku.sh: -------------------------------------------------------------------------------- 1 | heroku pg:psql postgresql-fluffy-56995 --app small-world-friends -------------------------------------------------------------------------------- /bin/start-dev.sh: -------------------------------------------------------------------------------- 1 | source bin/set-env-variables.sh && 2 | lein clean && 3 | lein repl -------------------------------------------------------------------------------- /bin/open-local-db.sh: -------------------------------------------------------------------------------- 1 | /Applications/Postgres.app/Contents/Versions/14/bin/psql -p5432 "devonzuegel" -------------------------------------------------------------------------------- /bin/postgres-local.sh: -------------------------------------------------------------------------------- 1 | /Applications/Postgres.app/Contents/Versions/14/bin/psql -p5432 "devonzuegel" -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | psql -U postgres -c "CREATE DATABASE \"smallworld-local\"" 4 | -------------------------------------------------------------------------------- /dev/design mocks/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/dev/design mocks/about.png -------------------------------------------------------------------------------- /resources/public/washi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/resources/public/washi.png -------------------------------------------------------------------------------- /Small World launch materials: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/Small World launch materials -------------------------------------------------------------------------------- /resources/public/az-subtle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/resources/public/az-subtle.png -------------------------------------------------------------------------------- /dev/design mocks/main screen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/dev/design mocks/main screen.jpg -------------------------------------------------------------------------------- /resources/public/devons_map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/resources/public/devons_map.png -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java -Xmx300M -Xss512k -XX:CICompilerCount=2 -Dfile.encoding=UTF-8 -jar target/smallworld.jar -m smallworld.web -------------------------------------------------------------------------------- /resources/public/kiwi-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/resources/public/kiwi-favicon.png -------------------------------------------------------------------------------- /resources/public/orange-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/resources/public/orange-favicon.png -------------------------------------------------------------------------------- /dev/design mocks/main screen map.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/dev/design mocks/main screen map.jpg -------------------------------------------------------------------------------- /resources/public/meetcute-opengraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/resources/public/meetcute-opengraph.png -------------------------------------------------------------------------------- /resources/public/oranges-tile-repeat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/resources/public/oranges-tile-repeat.jpg -------------------------------------------------------------------------------- /resources/public/oranges-tile-repeat-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devonzuegel/smallworld/HEAD/resources/public/oranges-tile-repeat-2.jpg -------------------------------------------------------------------------------- /src/ketchup/env.clj: -------------------------------------------------------------------------------- 1 | (ns ketchup.env) 2 | 3 | (defn get-env-var [key] 4 | (let [value (System/getenv key)] 5 | (when (nil? value) (throw (Throwable. (str "Environment variable not set: " key)))) 6 | value)) -------------------------------------------------------------------------------- /src/meetcute/screens/styles.cljc: -------------------------------------------------------------------------------- 1 | (ns meetcute.screens.styles) 2 | 3 | (def btn 4 | {:border "3px solid #cccccc33" 5 | :padding "12px" 6 | :border-radius "8px" 7 | :cursor "pointer" 8 | :margin "6px"}) -------------------------------------------------------------------------------- /src/meetcute/env.clj: -------------------------------------------------------------------------------- 1 | (ns meetcute.env) 2 | 3 | (defn get-env-var [key] 4 | (let [value (System/getenv key)] 5 | (assert (some? value) 6 | (str "Environment variable not set: " key)) 7 | value)) 8 | 9 | -------------------------------------------------------------------------------- /src/smallworld/session.cljc: -------------------------------------------------------------------------------- 1 | (ns smallworld.session #?(:cljs (:require [reagent.core :as r]))) 2 | 3 | (def blank {}) 4 | 5 | #?(:cljs 6 | (do 7 | (defonce *store (r/atom :loading)) ; TODO: add (^:private) later 8 | 9 | (defn update! [new-session-data] (reset! *store new-session-data)))) -------------------------------------------------------------------------------- /bin/heroku-logs.sh: -------------------------------------------------------------------------------- 1 | echo "" 2 | echo "--------------------------------------------------" 3 | echo " starting up the heroku logs" 4 | echo "--------------------------------------------------" 5 | 6 | echo "" 7 | echo "running command:" 8 | echo " heroku logs --tail" 9 | echo "" 10 | heroku logs --tail -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.iconTheme": "city-lights-icons-vsc-light", 3 | "editor.formatOnSave": true, 4 | "workbench.colorTheme": "Winter is Coming (Dark Blue)", 5 | "workbench.preferredLightColorTheme": "Horizon Bright", 6 | "window.autoDetectColorScheme": true, 7 | "workbench.preferredDarkColorTheme": "Winter is Coming (Dark Blue)", 8 | "audioCues.enabled": "off" 9 | } -------------------------------------------------------------------------------- /bin/deploy.sh: -------------------------------------------------------------------------------- 1 | echo "" 2 | echo "--------------------------------------------------" 3 | echo " deploying smallworld to heroku" 4 | echo "--------------------------------------------------" 5 | 6 | echo "" 7 | echo "running command:" 8 | echo " git push heroku HEAD:master # push the current branch to Heroku, whatever it’s called" 9 | echo "" 10 | git push heroku HEAD:master # push the current branch to Heroku, whatever it’s called 11 | 12 | bin/heroku-logs.sh 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | 8 | ], 9 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 10 | "unwantedRecommendations": [ 11 | 12 | ] 13 | } -------------------------------------------------------------------------------- /bin/run-server-jar.sh: -------------------------------------------------------------------------------- 1 | echo "" 2 | echo "--------------------------------------------------" 3 | echo " starting the smallworld server" 4 | echo "--------------------------------------------------" 5 | 6 | echo "" 7 | echo "running command:" 8 | echo " source bin/set-env-variables.sh" 9 | echo "" 10 | source bin/set-env-variables.sh 11 | 12 | echo "running command:" 13 | echo " java -jar target/smallworld.jar -m smallworld.web" 14 | echo "" 15 | java -jar target/smallworld.jar -m smallworld.web -------------------------------------------------------------------------------- /bin/make-jar.sh: -------------------------------------------------------------------------------- 1 | echo "" 2 | echo "--------------------------------------------------" 3 | echo " making the smallworld jar" 4 | echo "--------------------------------------------------" 5 | 6 | echo "" 7 | echo "running command:" 8 | echo " source bin/set-env-variables.sh" 9 | source bin/set-env-variables.sh 10 | 11 | echo "" 12 | echo "running command:" 13 | echo " lein clean" 14 | lein clean 15 | 16 | echo "" 17 | echo "running command:" 18 | echo " lein uberjar" 19 | echo "" 20 | lein uberjar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | *jar 3 | /classes/ 4 | /.lein-deps-sum 5 | /.lein-failures 6 | /.lein-env 7 | /checkouts 8 | /.env 9 | /target 10 | !/target/smallworld.jar 11 | /.lein-repl-history 12 | /.nrepl-port 13 | .clj-kondo/ 14 | .lsp/ 15 | .DS_Store 16 | dev/.DS_Store 17 | .calva/ 18 | figwheel_server.log 19 | .rebel_readline_history 20 | resources/public/js 21 | bin/set-env-variables.sh 22 | 23 | # IntelliJ files 24 | .idea/* 25 | *.iml 26 | 27 | # Database dumps (contain sensitive data) 28 | *.dump 29 | latest.dump 30 | -------------------------------------------------------------------------------- /resources/public/signup.js: -------------------------------------------------------------------------------- 1 | // Helper to resize Airtable iframe and present a loading message for it 2 | const airtableIframe = document.getElementById('airtable-signup'); 3 | const loadingSpinner = document.getElementById('loading-spinner'); 4 | 5 | function onLoad() { 6 | loadingSpinner.style.display = 'none'; 7 | airtableIframe.style.display = 'block'; 8 | }; 9 | 10 | if (airtableIframe) { 11 | const domHeight = window.innerHeight; 12 | const iframeHeight = domHeight - 180; 13 | airtableIframe.height = iframeHeight + 'px'; 14 | airtableIframe.onload = onLoad; 15 | } 16 | -------------------------------------------------------------------------------- /resources/public/signin.js: -------------------------------------------------------------------------------- 1 | 2 | // Manages the phone numbre input 3 | const displayPhoneInput = document.getElementById("display-phone"); 4 | const hiddenPhoneInput = document.getElementById("phone"); 5 | 6 | console.log('phone input', displayPhoneInput, hiddenPhoneInput); 7 | 8 | if (displayPhoneInput && hiddenPhoneInput) { 9 | const iti = window.intlTelInput(displayPhoneInput, { 10 | separateDialCode: true, 11 | initialCountry: "us", 12 | utilsScript: 13 | "https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/17.0.8/js/utils.js", 14 | }); 15 | 16 | displayPhoneInput.addEventListener('change', function(event) { 17 | var formattedNumber = iti.getNumber(intlTelInputUtils.numberFormat.E164); 18 | hiddenPhoneInput.value = formattedNumber; 19 | }); 20 | } 21 | 22 | -------------------------------------------------------------------------------- /dev/dev/repl.clj: -------------------------------------------------------------------------------- 1 | ;; this is nested under dev/dev/ to avoid namespace collisions. I know 2 | ;; it looks messy, but there's a reson for it! 3 | 4 | (ns dev.repl 5 | (:require [figwheel-sidecar.repl-api :as repl-api] 6 | [smallworld.web :as backend])) 7 | 8 | (def PORT 3001) 9 | 10 | #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} 11 | (defn restart-server [] 12 | (backend/stop!) 13 | (use 'smallworld.web :reload) 14 | (println "\nrestarting server......\n") 15 | (backend/-main)) 16 | 17 | (defn initialize-repl [] 18 | (println (str "\n\n🌎 starting the small world server (backend): http://localhost:" PORT " 🌍\n")) 19 | (backend/-main) 20 | 21 | (println "\n\n🎨 starting the Figwheel server (frontend hot-reloading) 🎨\n") 22 | (repl-api/start-figwheel!) 23 | 24 | (println (str "\n\n⚙️ (" 'restart-server ") – run this every time you update server-side code ⚙️")) 25 | (println "\n")) 26 | 27 | (initialize-repl) -------------------------------------------------------------------------------- /resources/sql/schema-impersonation.sql: -------------------------------------------------------------------------------- 1 | -- This is for Small World, not for Ketchup Club 2 | 3 | create table if not exists impersonation ( 4 | screen_name varchar(255), -- if null, then admin isn't impersonating 5 | created_at timestamp not null default current_timestamp, 6 | updated_at timestamp not null default current_timestamp 7 | ); 8 | 9 | --- split here --- 10 | 11 | -------------------------------------------------------------------------------- 12 | ---- auto-update updated_at ---------------------------------------------------- 13 | -------------------------------------------------------------------------------- 14 | 15 | CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() 16 | RETURNS trigger 17 | LANGUAGE plpgsql 18 | AS $function$ 19 | DECLARE 20 | _new record; 21 | BEGIN 22 | _new := NEW; 23 | _new."updated_at" = NOW(); 24 | RETURN _new; 25 | END; 26 | $function$; 27 | 28 | --- split here --- 29 | 30 | CREATE TRIGGER set_updated_at 31 | BEFORE UPDATE ON impersonation 32 | FOR EACH ROW 33 | EXECUTE FUNCTION set_current_timestamp_updated_at(); 34 | -------------------------------------------------------------------------------- /src/ketchup/auth.clj: -------------------------------------------------------------------------------- 1 | (ns ketchup.auth 2 | (:require [buddy.sign.jwt :as jwt] 3 | [clojure.string :as str] 4 | [cheshire.core :as json] 5 | [ketchup.env :as env])) 6 | 7 | (defn create-auth-token [user-id] 8 | {:pre [(some? user-id)]} 9 | (jwt/sign {:user-id user-id} (env/get-env-var "JWT_SECRET_KEY"))) 10 | 11 | (defn verify-auth-token [auth-token] 12 | (try 13 | (jwt/unsign auth-token (env/get-env-var "JWT_SECRET_KEY")) 14 | (catch Exception _e 15 | nil))) 16 | 17 | (defn get-authorization-token [req] 18 | (some-> (get-in req [:headers "authorization"]) 19 | (str/split #"\s+") 20 | (second))) 21 | 22 | (def unauthorized-response 23 | {:status 401 24 | :headers {"Content-Type" "application/json"} 25 | :body (json/generate-string {:error "unauthorized to ketchup API"})}) 26 | 27 | (defn wrap-authenticated [handler] 28 | (fn [request] 29 | (if-let [auth-token (get-authorization-token request)] 30 | (if-let [jwt (verify-auth-token auth-token)] 31 | (handler (assoc request :auth/parsed-jwt jwt)) 32 | unauthorized-response) 33 | unauthorized-response))) -------------------------------------------------------------------------------- /resources/public/heroku-errors/application-error.html: -------------------------------------------------------------------------------- 1 | 7 | 32 |
33 |

Small World is overloaded

34 |

sorry, Small World is a little more popular than we anticipated!

35 |

if you refresh, the website should be back up in a few seconds

36 |
37 | -------------------------------------------------------------------------------- /bin/set-env-variables TEMPLATE.sh: -------------------------------------------------------------------------------- 1 | export AIRTABLE_BASE_API_KEY=get from https://airtable.com 2 | export BING_MAPS_API_KEY=get from Bing Developer 3 | export COOKIE_STORE_SECRET_KEY=generate a 16-char random string 4 | export DATABASE_URL=set up in Heroku 5 | export ENVIRONMENT=e.g. dev-m1-macbook 6 | export EXPO_PUSH_TOKEN= 7 | export JAVA_OPTS="-Xmx300m -Xss512k -XX:CICompilerCount=2 -Dfile.encoding=UTF-8" # match the max heap/stack size set by Heroku 8 | export JWT_SECRET_KEY=generate a 5-word random string 9 | export LEIN_JVM_OPTS="-XX:TieredStopAtLevel=1" # suppresses OpenJDK 64-Bit Server VM warning: https://stackoverflow.com/a/67695691/2639250 10 | export SENDGRID_API_KEY=get from https://sendgrid.com 11 | export SMS_CODE=generate a random code here 12 | export TWILIO_AUTH_TOKEN=get from twilio.com 13 | export TWILIO_PHONE_NUMBER=+16502295016 14 | export TWILIO_SID=get from twilio.com 15 | export TWILIO_VERIFY_SERVICE=VAc2b8caa89134e9b10b31e67d4468a637 16 | export TWITTER_ACCESS_TOKEN_SECRET=get from https://developer.twitter.com 17 | export TWITTER_ACCESS_TOKEN=get from https://developer.twitter.com 18 | export TWITTER_CONSUMER_KEY=get from https://developer.twitter.com 19 | export TWITTER_CONSUMER_SECRET=get from https://developer.twitter.com 20 | 21 | # when you add a new environment variable to to this file, make 22 | # sure to also add it to `bin/set-env-variables.sh` -------------------------------------------------------------------------------- /resources/sql/schema-friends.sql: -------------------------------------------------------------------------------- 1 | -- This is for Small World, not for Ketchup Club 2 | 3 | create table if not exists friends ( 4 | id integer primary key generated always as identity, 5 | request_key varchar(255) not null unique, 6 | data json, 7 | created_at timestamp not null default current_timestamp, 8 | updated_at timestamp not null default current_timestamp 9 | ) 10 | 11 | --- split here --- 12 | 13 | -------------------------------------------------------------------------------- 14 | ---- auto-update updated_at ---------------------------------------------------- 15 | -------------------------------------------------------------------------------- 16 | 17 | CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() 18 | RETURNS trigger 19 | LANGUAGE plpgsql 20 | AS $function$ 21 | DECLARE 22 | _new record; 23 | BEGIN 24 | _new := NEW; 25 | _new."updated_at" = NOW(); 26 | RETURN _new; 27 | END; 28 | $function$; 29 | 30 | --- split here --- 31 | 32 | CREATE TRIGGER set_updated_at 33 | BEFORE UPDATE ON friends 34 | FOR EACH ROW 35 | EXECUTE FUNCTION set_current_timestamp_updated_at(); 36 | 37 | -------------------------------------------------------------------------------- 38 | ---- add an index on request_key ----------------------------------------------- 39 | -------------------------------------------------------------------------------- 40 | 41 | --- split here --- 42 | 43 | CREATE INDEX index__request_key__friends 44 | ON friends (request_key); 45 | -------------------------------------------------------------------------------- /resources/sql/schema-coordinates.sql: -------------------------------------------------------------------------------- 1 | -- This is for Small World, not for Ketchup Club 2 | 3 | create table if not exists coordinates ( 4 | id integer primary key generated always as identity, 5 | request_key varchar(255) not null unique, 6 | data json, 7 | created_at timestamp not null default current_timestamp, 8 | updated_at timestamp not null default current_timestamp 9 | ) 10 | 11 | --- split here --- 12 | 13 | -------------------------------------------------------------------------------- 14 | ---- auto-update updated_at ---------------------------------------------------- 15 | -------------------------------------------------------------------------------- 16 | 17 | CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() 18 | RETURNS trigger 19 | LANGUAGE plpgsql 20 | AS $function$ 21 | DECLARE 22 | _new record; 23 | BEGIN 24 | _new := NEW; 25 | _new."updated_at" = NOW(); 26 | RETURN _new; 27 | END; 28 | $function$; 29 | 30 | --- split here --- 31 | 32 | CREATE TRIGGER set_updated_at 33 | BEFORE UPDATE ON coordinates 34 | FOR EACH ROW 35 | EXECUTE FUNCTION set_current_timestamp_updated_at(); 36 | 37 | -------------------------------------------------------------------------------- 38 | ---- add an index on request_key ----------------------------------------------- 39 | -------------------------------------------------------------------------------- 40 | 41 | --- split here --- 42 | 43 | CREATE INDEX index__request_key__coordinates 44 | ON coordinates (request_key); 45 | -------------------------------------------------------------------------------- /resources/sql/schema-access-tokens.sql: -------------------------------------------------------------------------------- 1 | -- This is for Small World, not for Ketchup Club 2 | 3 | create table if not exists access_tokens ( 4 | id integer primary key generated always as identity, 5 | request_key varchar(255) not null unique, 6 | data json, 7 | created_at timestamp not null default current_timestamp, 8 | updated_at timestamp not null default current_timestamp 9 | ) 10 | 11 | --- split here --- 12 | 13 | -------------------------------------------------------------------------------- 14 | ---- auto-update updated_at ---------------------------------------------------- 15 | -------------------------------------------------------------------------------- 16 | 17 | CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() 18 | RETURNS trigger 19 | LANGUAGE plpgsql 20 | AS $function$ 21 | DECLARE 22 | _new record; 23 | BEGIN 24 | _new := NEW; 25 | _new."updated_at" = NOW(); 26 | RETURN _new; 27 | END; 28 | $function$; 29 | 30 | --- split here --- 31 | 32 | CREATE TRIGGER set_updated_at 33 | BEFORE UPDATE ON access_tokens 34 | FOR EACH ROW 35 | EXECUTE FUNCTION set_current_timestamp_updated_at(); 36 | 37 | -------------------------------------------------------------------------------- 38 | ---- add an index on request_key ----------------------------------------------- 39 | -------------------------------------------------------------------------------- 40 | 41 | --- split here --- 42 | 43 | CREATE INDEX index__request_key__access_tokens 44 | ON access_tokens (request_key); 45 | -------------------------------------------------------------------------------- /resources/sql/schema-users.sql: -------------------------------------------------------------------------------- 1 | -- TODO: in the future, may want to add an index on `phone` and `screen_name` 2 | 3 | create table if not exists users ( 4 | id integer primary key generated always as identity, 5 | phone varchar(255) not null unique, 6 | last_ping timestamp not null default current_timestamp, 7 | status varchar(255) not null default 'offline', 8 | screen_name varchar(255) not null unique, 9 | name varchar(255), 10 | email_address varchar(255), 11 | email_notifications varchar(255), 12 | push_token varchar(255), 13 | created_at timestamp not null default current_timestamp, 14 | updated_at timestamp not null default current_timestamp 15 | ); 16 | 17 | --- split here --- 18 | 19 | -------------------------------------------------------------------------------- 20 | ---- auto-update updated_at ---------------------------------------------------- 21 | -------------------------------------------------------------------------------- 22 | 23 | CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() 24 | RETURNS trigger 25 | LANGUAGE plpgsql 26 | AS $function$ 27 | DECLARE 28 | _new record; 29 | BEGIN 30 | _new := NEW; 31 | _new."updated_at" = NOW(); 32 | RETURN _new; 33 | END; 34 | $function$; 35 | 36 | --- split here --- 37 | 38 | CREATE TRIGGER set_updated_at 39 | BEFORE UPDATE ON users 40 | FOR EACH ROW 41 | EXECUTE FUNCTION set_current_timestamp_updated_at(); 42 | -------------------------------------------------------------------------------- /resources/sql/schema-twitter-profiles.sql: -------------------------------------------------------------------------------- 1 | -- This is for Small World, not for Ketchup Club 2 | 3 | create table if not exists twitter_profiles ( 4 | id integer primary key generated always as identity, 5 | request_key varchar(255) not null unique, 6 | data json, 7 | created_at timestamp not null default current_timestamp, 8 | updated_at timestamp not null default current_timestamp 9 | ) 10 | 11 | --- split here --- 12 | 13 | -------------------------------------------------------------------------------- 14 | ---- auto-update updated_at ---------------------------------------------------- 15 | -------------------------------------------------------------------------------- 16 | 17 | CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() 18 | RETURNS trigger 19 | LANGUAGE plpgsql 20 | AS $function$ 21 | DECLARE 22 | _new record; 23 | BEGIN 24 | _new := NEW; 25 | _new."updated_at" = NOW(); 26 | RETURN _new; 27 | END; 28 | $function$; 29 | 30 | --- split here --- 31 | 32 | CREATE TRIGGER set_updated_at 33 | BEFORE UPDATE ON twitter_profiles 34 | FOR EACH ROW 35 | EXECUTE FUNCTION set_current_timestamp_updated_at(); 36 | 37 | -------------------------------------------------------------------------------- 38 | ---- add an index on request_key ----------------------------------------------- 39 | -------------------------------------------------------------------------------- 40 | 41 | --- split here --- 42 | 43 | CREATE INDEX index__request_key__twitter_profiles 44 | ON twitter_profiles (request_key); 45 | -------------------------------------------------------------------------------- /src/ketchup/notify.clj: -------------------------------------------------------------------------------- 1 | (ns ketchup.notify 2 | (:require [ketchup.db :as db] 3 | [ketchup.env :as env] 4 | ;; [clojure.pprint :as pp] 5 | [sdk.expo :as expo])) 6 | 7 | (defn status-change!! [user-id sender-new-status] 8 | (when (= sender-new-status "online") ; for now, only notify when user goes online 9 | (let [sender-new-status (case sender-new-status 10 | "online" "online 🟢" 11 | "offline" "offline 🔵" 12 | sender-new-status) 13 | expo-push-token (env/get-env-var "EXPO_PUSH_TOKEN") 14 | sender (db/user-by-id user-id) 15 | _ (assert sender) 16 | recipients (->> (db/get-all-users) 17 | (filter #(and (:push_token %) 18 | (not= user-id (:id %)))) 19 | (mapv (fn [recipient] 20 | (println) 21 | (println "recipient" (:id recipient) "·" (:push_token recipient)) 22 | {:to (:push_token recipient) 23 | :title (format "%s just went %s" (:screen_name sender) sender-new-status) 24 | :body (if (= sender-new-status "online 🟢") 25 | "free to ketchup?" 26 | "don't worry, you can ketchup later!")})))] 27 | (println "🐿️ " user-id "changed to" sender-new-status) 28 | ;; (pp/pprint recipients) 29 | (expo/push-many! expo-push-token recipients)))) 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/meetcute/sms.clj: -------------------------------------------------------------------------------- 1 | (ns meetcute.sms 2 | (:require [clj-http.client :as http] 3 | [meetcute.env :as env])) 4 | 5 | (defn code-template [code] 6 | (format "%s is your MeetCute verification code" code)) 7 | 8 | (defn send! [{:keys [phone message]}] 9 | {:pre [(string? phone) (string? message)]} 10 | (let [sid (env/get-env-var "TWILIO_SID")] 11 | (http/post 12 | (format "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json" sid) 13 | {:basic-auth [sid (env/get-env-var "TWILIO_AUTH_TOKEN")] 14 | ;; :content-type :json 15 | :form-params {:From (env/get-env-var "TWILIO_PHONE_NUMBER") 16 | :To phone 17 | :Body message}}))) 18 | 19 | (defn start-verification! 20 | "Starts a verification from Twilio's service. 21 | Returns an id for its session or an error." 22 | [{:keys [phone]}] 23 | (-> (format "https://verify.twilio.com/v2/Services/%s/Verifications" 24 | (env/get-env-var "TWILIO_VERIFY_SERVICE")) 25 | (http/post 26 | {:basic-auth [(env/get-env-var "TWILIO_SID") 27 | (env/get-env-var "TWILIO_AUTH_TOKEN")] 28 | :as :json 29 | :form-params {:Channel "sms" :To phone}}) 30 | :body 31 | :sid)) 32 | 33 | (defn check-code! 34 | "Checks if the code is the right one. Returns either true or false if the code is invalid" 35 | [{:keys [phone code]}] 36 | (-> (format "https://verify.twilio.com/v2/Services/%s/VerificationCheck" 37 | (env/get-env-var "TWILIO_VERIFY_SERVICE")) 38 | (http/post 39 | {:basic-auth [(env/get-env-var "TWILIO_SID") 40 | (env/get-env-var "TWILIO_AUTH_TOKEN")] 41 | :as :json 42 | :form-params {:Code code :To phone}}) 43 | :body 44 | :valid)) 45 | 46 | -------------------------------------------------------------------------------- /resources/sql/schema-settings.sql: -------------------------------------------------------------------------------- 1 | -- This is for Small World, not for Ketchup Club 2 | 3 | create table if not exists settings ( 4 | id integer primary key generated always as identity, 5 | screen_name varchar(255) not null unique, 6 | name varchar(255), 7 | twitter_avatar varchar(255), 8 | welcome_flow_complete boolean not null default false, 9 | locations json, 10 | friends_refresh json, 11 | email_address varchar(255), 12 | email_notifications varchar(255), 13 | twitter_last_fetched timestamp not null default current_timestamp, 14 | created_at timestamp not null default current_timestamp, 15 | updated_at timestamp not null default current_timestamp 16 | ); 17 | 18 | --- split here --- 19 | 20 | -------------------------------------------------------------------------------- 21 | ---- auto-update updated_at ---------------------------------------------------- 22 | -------------------------------------------------------------------------------- 23 | 24 | CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() 25 | RETURNS trigger 26 | LANGUAGE plpgsql 27 | AS $function$ 28 | DECLARE 29 | _new record; 30 | BEGIN 31 | _new := NEW; 32 | _new."updated_at" = NOW(); 33 | RETURN _new; 34 | END; 35 | $function$; 36 | 37 | --- split here --- 38 | 39 | CREATE TRIGGER set_updated_at 40 | BEFORE UPDATE ON settings 41 | FOR EACH ROW 42 | EXECUTE FUNCTION set_current_timestamp_updated_at(); 43 | 44 | -------------------------------------------------------------------------------- 45 | ---- add an index on screen_name ----------------------------------------------- 46 | -------------------------------------------------------------------------------- 47 | 48 | --- split here --- 49 | 50 | CREATE INDEX index__screen_name__settings 51 | ON settings (screen_name); 52 | -------------------------------------------------------------------------------- /src/smallworld/util.clj: -------------------------------------------------------------------------------- 1 | (ns smallworld.util 2 | (:require [clojure.pprint :as pp] 3 | [clojure.data.json :as json] 4 | [clojure.java.io :as io])) 5 | 6 | #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} 7 | (defn store-to-file [filename data] 8 | (let [result (with-out-str (pr data))] 9 | (spit filename result))) 10 | 11 | #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} 12 | (defn read-from-file [filename] 13 | (read-string (slurp (io/resource filename)))) 14 | 15 | (defn read-json-from-file [filename] 16 | (json/read-str (slurp (io/resource filename)) :key-fn keyword)) 17 | 18 | (defn get-env-var [key] 19 | (let [value (System/getenv key)] 20 | (when (nil? value) (throw (Throwable. (str "Environment variable not set: " key)))) 21 | value)) 22 | 23 | #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} 24 | (defn server-logger [handler] 25 | (fn [request] 26 | (println "\n=======================================================") 27 | (println "request:") 28 | (pp/pprint request) 29 | (println "") 30 | (let [response (handler request)] 31 | (println "response:") 32 | (pp/pprint response) 33 | (println "=======================================================\n") 34 | response))) 35 | 36 | (defn exclude-keys [m keys-to-exclude] 37 | (reduce dissoc m keys-to-exclude)) 38 | 39 | (defn in? [string array] 40 | (some #(= string %) array)) 41 | 42 | (defn rand-str [len] 43 | (apply str 44 | (for [i (range len)] 45 | (char (+ (rand 26) 65))))) 46 | 47 | (defn timestamp [] (str (new java.util.Date))) 48 | 49 | (defn str-keys-to-keywords [m] 50 | (into {} (map (fn [[k v]] 51 | (if (string? k) 52 | [(keyword k) (if (map? v) 53 | (str-keys-to-keywords v) 54 | v)] 55 | [k v])) 56 | m))) 57 | 58 | (defn none-nil? [& values] 59 | (every? identity values)) 60 | 61 | (defn round [num places] 62 | (let [num (* num (Math/pow 10 places))] 63 | (/ (Math/round num) (Math/pow 10 places)))) 64 | 65 | (defn log [string] 66 | (println (timestamp) "--" string)) 67 | 68 | (def ENVIRONMENTS {:prod "prod-heroku" 69 | :local "dev-m1-macbook"}) -------------------------------------------------------------------------------- /src/sdk/expo.clj: -------------------------------------------------------------------------------- 1 | (ns sdk.expo 2 | (:require [clj-http.client :as http] 3 | [clojure.pprint :as pp] 4 | [clojure.data.json :as json])) 5 | 6 | (def base-url "https://exp.host/--") 7 | 8 | ;; From https://docs.expo.dev/push-notifications/sending-notifications/ 9 | 10 | ;; curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/send" -d '{ 11 | ;; "to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", 12 | ;; "title":"hello", 13 | ;; "body": "world" 14 | ;; }' 15 | 16 | (comment 17 | {:to "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]" 18 | :title "hello" 19 | :body "world"}) 20 | 21 | (defn valid-notification? [{:keys [to title body]}] 22 | (and (string? to) 23 | (string? title) 24 | (string? body))) 25 | 26 | (comment 27 | ;; example response 28 | {:data {:status "ok", :id "e9a83bbe-377a-45d7-a0d6-7b281c809e19"}}) 29 | 30 | (defn push-one! 31 | 32 | [expo-push-token {:keys [device-token title body] :as notification}] 33 | 34 | {:pre [(valid-notification? notification)]} 35 | 36 | (let [data {:to device-token 37 | :title title 38 | :body body} 39 | r (-> (str base-url "/api/v2/push/send") 40 | (http/post 41 | {:as :json 42 | :body (json/write-str data) 43 | :headers {"Content-Type" "application/json" 44 | "Authorization" (str "Bearer " expo-push-token)}}))] 45 | (if (= 200 (:status r)) 46 | (:data (:body r)) 47 | (throw (ex-info "Failed to send push notification" 48 | {:status (:status r) 49 | :body (:body r)}))))) 50 | 51 | (def MAX_EXPO_NOTIFICATIONS 100) 52 | 53 | (defn push-many! 54 | 55 | [expo-push-token notifications] 56 | 57 | {:pre [(string? expo-push-token) 58 | (< (count notifications) MAX_EXPO_NOTIFICATIONS) 59 | (every? valid-notification? notifications)]} 60 | 61 | (let [r (-> (str base-url "/api/v2/push/send") 62 | (http/post 63 | {:as :json 64 | :body (json/write-str notifications) 65 | :headers {"Content-Type" "application/json" 66 | "Authorization" (str "Bearer " expo-push-token)}}))] 67 | (println) 68 | (println "push-many! ================================================") 69 | (println) 70 | (pp/pprint r) 71 | (println) 72 | (println r) 73 | (println) 74 | (println "===========================================================") 75 | (if (= 200 (:status r)) 76 | (:data (:body r)) 77 | (throw (ex-info "Failed to send push notification" 78 | {:status (:status r) 79 | :body (:body r)}))))) -------------------------------------------------------------------------------- /resources/public/meetcute.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MeetCute 5 | 6 | 7 | 8 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Small World 5 | 6 | 7 | 8 | 16 | 17 | 18 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/ketchup/db.clj: -------------------------------------------------------------------------------- 1 | (ns ketchup.db 2 | (:require [clojure.java.jdbc :as sql] 3 | [clojure.pprint :as pp] 4 | [clojure.string :as str] 5 | [clojure.walk :as walk] 6 | [jdbc.pool.c3p0 :as pool] 7 | [ketchup.env :as env])) 8 | 9 | ;; ================================================================== 10 | ;; DB connection 11 | 12 | (def debug? false) 13 | 14 | (def db-uri (java.net.URI. (env/get-env-var "DATABASE_URL"))) 15 | 16 | (def user-and-password 17 | (if (nil? (.getUserInfo db-uri)) 18 | nil (clojure.string/split (.getUserInfo db-uri) #":"))) 19 | 20 | (def pool 21 | (delay 22 | (pool/make-datasource-spec 23 | {:classname "org.postgresql.Driver" 24 | :subprotocol "postgresql" 25 | :user (get user-and-password 0) 26 | :password (get user-and-password 1) 27 | :subname (if (= -1 (.getPort db-uri)) 28 | (format "//%s%s" (.getHost db-uri) (.getPath db-uri)) 29 | (format "//%s:%s%s" (.getHost db-uri) (.getPort db-uri) (.getPath db-uri)))}))) 30 | 31 | ;; ================================================================== 32 | ;; Private utils 33 | 34 | (defn escape-str [str] ; TODO: replace this this the ? syntax, which escapes for you 35 | (str/replace str "'" "''")) 36 | 37 | (defn where [column-name value] 38 | (str " where " (name column-name) " = '" (escape-str value) "'")) 39 | 40 | (defn insert! [table-name data] 41 | (when debug? 42 | (println "inserting the following data into" table-name) 43 | (pp/pprint data)) 44 | (sql/insert! @pool table-name data)) 45 | 46 | (defn select-by-col [table-name col-name col-value] 47 | (when debug? 48 | (println "(select-by-col" table-name col-name col-value ")")) 49 | (if (nil? col-value) 50 | [] 51 | (walk/keywordize-keys 52 | (sql/query @pool (str "select * from " (name table-name) 53 | (where col-name col-value)))))) 54 | 55 | (defn find-or-insert! [table-name col-name data] 56 | (let [found-in-db (select-by-col table-name col-name (get-in data [col-name]))] 57 | (if (empty? found-in-db) 58 | (insert! table-name data) 59 | (first found-in-db)))) 60 | 61 | (defn select-all [table] 62 | (sql/query @pool (str "select * from " (name table)))) 63 | 64 | ;; ================================================================== 65 | ;; API 66 | 67 | (def users-table :users) ;; stores screen_name of the user who the admin is impersonating (for debug only) 68 | 69 | (defn user-by-id [id] 70 | (first 71 | (sql/query @pool ["select * from users where id = ?" id]))) 72 | 73 | (defn user-by-phone [phone] 74 | (first 75 | (sql/query @pool ["select * from users where phone = ?" phone]))) 76 | 77 | (defn set-push-token! [id token] 78 | {:pre [(or (string? token) 79 | (nil? token))]} 80 | (sql/execute! @pool ["update users set push_token = ? where id = ?" token id])) 81 | 82 | (defn find-or-insert-user! [{:keys [phone]}] 83 | (if-let [user (user-by-phone phone)] 84 | user 85 | (let [user-data {:phone phone :screen_name phone}] 86 | (sql/insert! @pool users-table user-data) 87 | (user-by-phone phone)))) 88 | 89 | (defn update-user-last-ping! [id status] 90 | (println "updating user last ping for id" id "to" status) 91 | (sql/db-do-commands @pool (str "update users set last_ping = now(), status = '" status "' " 92 | "where id = '" id "';"))) 93 | 94 | (defn get-all-users [] 95 | (select-all users-table)) -------------------------------------------------------------------------------- /src/smallworld/email.clj: -------------------------------------------------------------------------------- 1 | (ns smallworld.email (:require [clj-http.client :as http] 2 | [clojure.pprint :as pp] 3 | [smallworld.util :as util])) 4 | 5 | (def debug? true) 6 | (def FROM_EMAIL "hello@smallworld.kiwi") 7 | (def FROM_NAME "Small World") 8 | (def TEMPLATES {:welcome "d-4cb1507efaaa4a2eab8a9f18b0dabbc5" 9 | :friends-on-the-move "d-75f5a6ca89484938b92b5f01d883de1b"}) 10 | 11 | (defn log-event [name data] 12 | (util/log (str name ": " data))) 13 | 14 | (defn- send-with-content [{to-email :to 15 | from-name :from-name 16 | subject :subject 17 | type :type 18 | body :body}] 19 | (http/post 20 | "https://api.sendgrid.com/v3/mail/send" 21 | {:headers {:authorization (str "Bearer " (util/get-env-var "SENDGRID_API_KEY"))} 22 | :content-type :json 23 | :form-params {:personalizations [{:to [{:email to-email}] 24 | :subject subject}] 25 | :from {:email FROM_EMAIL 26 | :name (or from-name FROM_NAME)} 27 | :content [{:type (or type "text/html") :value body}]}})) 28 | 29 | (defn- send-with-template [{to-email :to 30 | template-id :template 31 | dynamic-template-data :dynamic_template_data}] 32 | (when debug? (println) 33 | (println "template-id: " template-id) 34 | (println "to-email: " to-email) 35 | (println)) 36 | (http/post 37 | "https://api.sendgrid.com/v3/mail/send" 38 | {:headers {:authorization (str "Bearer " (util/get-env-var "SENDGRID_API_KEY"))} 39 | :content-type :json 40 | :form-params {:template_id template-id 41 | :personalizations [{:to [{:email to-email}] 42 | :dynamic_template_data dynamic-template-data}] 43 | :from {:email FROM_EMAIL 44 | :name FROM_NAME}}})) 45 | 46 | (defn send-email [options] 47 | (let [old-to-email (:to options) 48 | env (util/get-env-var "ENVIRONMENT") 49 | options (if (= env (:prod util/ENVIRONMENTS)) 50 | options 51 | (assoc options :to "hello@smallworld.kiwi"))] 52 | 53 | (println) 54 | (println "preparing to send email with the following config: ===========================================") 55 | (pp/pprint (assoc options :body "[REDACTED]")) 56 | 57 | (when (and (not= env (:prod util/ENVIRONMENTS)) 58 | (not= old-to-email "avery.sara.james@gmail.com")) 59 | (println) 60 | (println "NOTE: Sending email to" (:to options) "instead of" old-to-email ", because we're not in prod and we don't want to spam our users :)") 61 | (println)) 62 | 63 | (try (if (:template options) 64 | (send-with-template options) 65 | (send-with-content options)) 66 | (catch Throwable e 67 | (util/log "failed to send email (error below), continuing...") 68 | (log-event "send-email-failed" {:error e}) 69 | (util/log e))) 70 | 71 | (println "==============================================================================================") 72 | (println))) 73 | 74 | (comment 75 | (send-email {:to "devonzuegel@gmail.com" 76 | :subject "test from CLI" 77 | :body "test from CLI" 78 | :type "text/plain"})) 79 | -------------------------------------------------------------------------------- /src/smallworld/memoize.clj: -------------------------------------------------------------------------------- 1 | (ns smallworld.memoize 2 | (:refer-clojure :exclude [memoize]) 3 | (:require [smallworld.db :as db] 4 | [clojure.walk :as walk])) 5 | 6 | (def debug? false) 7 | 8 | (defprotocol ICache 9 | ; TODO: consider additing a #validate method, which I'd use for the db version 10 | (save! [this request-key value]) ; manages a newly fetched value 11 | (read! [this request-key])) ; retrieves a value that was previously fetched 12 | 13 | (extend-protocol ICache 14 | clojure.lang.Atom 15 | (save! [this request-key value] (swap! this #(assoc % request-key value))) 16 | (read! [this request-key] (get @this request-key ::not-found)) 17 | 18 | java.io.File 19 | (save! [this request-key value] 20 | ; creates new file & returns true iff it doesn't exist 21 | (when (.createNewFile this) (spit this "{}")) 22 | (spit this (assoc (read-string (slurp this)) request-key value))) 23 | (read! [this request-key] 24 | ; creates new file & returns true iff it doesn't exist 25 | (when (.createNewFile this) (spit this "{}")) 26 | (get (read-string (slurp this)) request-key ::not-found)) 27 | 28 | clojure.lang.Keyword 29 | (save! [table-name request-key result] 30 | (when debug? 31 | (println) 32 | (println "---------- save! was called ---------------") 33 | (println) 34 | (println " table-name: " table-name) 35 | (println " request-key: " request-key) 36 | (println " result: " (count result)) 37 | (println table-name)) 38 | ; store result so it doesn't have to be fetched again 39 | (db/insert-or-update! table-name :request_key {:request_key request-key :data result}) 40 | result) 41 | (read! [table-name request-key] 42 | (let [results (db/select-by-col table-name :request_key request-key)] 43 | (when debug? 44 | (println) 45 | (println "---------- read! was called ---------------") 46 | (println) 47 | (println " table-name: " table-name) 48 | (println " request-key: " request-key) 49 | ;; (println " results: " results) 50 | ) 51 | (if (= 0 (count results)) 52 | ::not-found 53 | (walk/keywordize-keys (:data (first results))))))) 54 | 55 | (defn my-memoize 56 | ([expensive-fn cache] 57 | (when debug? (println "\ninitializing cache: " (str cache))) 58 | (fn [& [request-key & optional-args :as all-args]] 59 | (when debug? 60 | (println " all-args: " all-args) 61 | (println " optional-args: " (or optional-args "no optional args")) 62 | (println " request-key: " request-key)) 63 | 64 | (assert (string? request-key) "my-memoize requires the request key to be a string") 65 | 66 | (if (= ::not-found (read! cache request-key)) 67 | ;; if we haven't seen the request before, then we need to compute the value 68 | (let [result (if optional-args 69 | (apply expensive-fn all-args) 70 | (expensive-fn request-key))] 71 | (if (= :failed result) 72 | ;; if the expensive function failed, don't cache the result 73 | (do (when debug? 74 | (println "\n🔴 failed to fetch result for: " request-key) 75 | (println)) 76 | :failed) 77 | ;; else if the expensive function succeeded, cache the result 78 | (do (when debug? 79 | (println "\n🟢 fetch for first time: " request-key #_" → " #_result) 80 | (println)) 81 | (save! cache request-key result) 82 | result))) 83 | 84 | ;; else if we've seen the request before, then just return the cached value 85 | (let [result (read! cache request-key)] 86 | (when debug? 87 | (println "\n🟡 retrieving stored result: " request-key #_" → " #_result) 88 | (println)) 89 | result))))) 90 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject smallworld "0.1.0-SNAPSHOT" 2 | :description "FIXME: write description" 3 | :url "http://example.com/FIXME" 4 | :license {:name "" 5 | :url ""} 6 | :clean-targets ^{:protect false} [:target-path "out" "resources/public/js"] 7 | :min-lein-version "2.5.3" 8 | :repl-options {:init-ns dev.repl 9 | :timeout 380000} 10 | :dependencies [[org.clojure/clojure "1.12.0"] 11 | [org.clojure/clojurescript "1.10.520"] 12 | [reagent "0.9.0-rc1"] 13 | [compojure "1.6.2"] 14 | [twttr "3.2.3"] 15 | [cheshire "5.10.1"] 16 | [oauth-clj "0.1.16"] 17 | [org.xerial/sqlite-jdbc "3.34.0"] 18 | [metosin/reitit "0.5.18"] 19 | [hiccup "2.0.0-RC2"] 20 | [enlive "1.1.6"] 21 | [cljsjs/mapbox "0.46.0-0"] 22 | [ring/ring-jetty-adapter "1.9.5"] 23 | [ring/ring-ssl "0.3.0"] 24 | [ring/ring-core "1.11.0"] 25 | [ring/ring-defaults "0.4.0"] 26 | [yesql "0.5.3"] 27 | [factual/timely "0.0.3"] 28 | [ring-cors "0.1.13"] 29 | [markdown-clj "1.11.7"] 30 | [org.clojure/data.json "2.4.0"] 31 | [org.clojure/core.memoize "1.0.257"] 32 | [clj-fuzzy "0.4.1"] 33 | [buddy/buddy-sign "3.5.351"] 34 | [prismatic/schema "1.2.0"] 35 | [clojure.jdbc/clojure.jdbc-c3p0 "0.3.1"] 36 | [org.clojure/java.jdbc "0.7.12"] 37 | [org.postgresql/postgresql "9.4-1201-jdbc41"] 38 | [environ "1.1.0"]] 39 | :plugins [[lein-environ "1.1.0" :hooks false] 40 | [lein-cljsbuild "1.1.7"] 41 | [lein-figwheel "0.5.19"]] 42 | :figwheel {:css-dirs ["resources/public/css"] 43 | :server-port 3450 44 | ;; :ring-handler smallworld.web/app ;; run the backend too 45 | ;; :nrepl-port 7888 46 | } 47 | :uberjar-name "smallworld.jar" 48 | :profiles {:dev {:dependencies [[cider/piggieback "0.4.1"] 49 | [figwheel-sidecar "0.5.18"] 50 | ;; [binaryage/devtools "0.9.10"] 51 | ] 52 | :source-paths ["src" "dev"] 53 | :cljsbuild {:builds [{:id "dev" 54 | :source-paths ["src"] 55 | :figwheel true 56 | :compiler {:main "smallworld.frontend" 57 | :asset-path "js/out" 58 | :output-to "resources/public/js/main.js" 59 | :output-dir "resources/public/js/out" 60 | :optimizations :none 61 | :source-map true}}]}} 62 | :uberjar {:env {:production true} 63 | :source-paths ["src"] 64 | :jvm-opts ["-Dclojure.compiler.direct-linking=true"] 65 | :aot :all 66 | :prep-tasks ["compile" ["cljsbuild" "once"]] ; can comment this out in dev to make it faster to build the serverside code 67 | :cljsbuild {:builds [{:id "production" 68 | :source-paths ["src"] 69 | :jar true 70 | :compiler {:main "smallworld.frontend" 71 | :asset-path "js/out" 72 | :output-to "resources/public/js/main.js" 73 | :output-dir "resources/public/js/out" 74 | :optimizations :advanced 75 | :pretty-print false}}]}}}) 76 | -------------------------------------------------------------------------------- /src/smallworld/admin.cljc: -------------------------------------------------------------------------------- 1 | (ns smallworld.admin 2 | #?(:cljs (:require [reagent.core :as r] 3 | [smallworld.session :as session] 4 | [smallworld.util :as util] 5 | [smallworld.decorations :as decorations] 6 | [clojure.pprint :as pp])) 7 | #?(:clj (:require [ring.util.response :as response] 8 | [smallworld.db :as db] 9 | [cheshire.core :as cheshire]))) 10 | 11 | (def screen-names ["devonzuegel" 12 | "devon_dos" 13 | "meadowmaus"]) 14 | 15 | (defn in? [string array] 16 | (some #(= string %) array)) 17 | 18 | (defn is-admin [user] 19 | ;; (println "checking if is-admin: " user) 20 | ;; (println " screen-name: " (:screen-name user)) 21 | (let [screen-name (:screen-name user)] 22 | (and 23 | (not-empty screen-name) 24 | (not (nil? screen-name)) 25 | (in? screen-name screen-names)))) 26 | 27 | 28 | #?(:cljs 29 | (do 30 | (defonce admin-summary* (r/atom :loading)) 31 | 32 | (defn screen [] ; TODO: fetch admin data on screen load – probably needs react effects to do it properly 33 | [:div.admin-screen 34 | (if-not (is-admin @session/*store) 35 | 36 | (if (= :loading @session/*store) 37 | (decorations/loading-screen) 38 | [:p {:style {:margin "30vh auto 0 auto" :text-align "center" :font-size "2em"}} 39 | "not found"]) 40 | 41 | [:<> 42 | [:a.btn {:on-click #(util/fetch "/api/v1/admin/refresh_all_users_friends" (fn [result] 43 | (pp/pprint result)))} 44 | "refresh all users' friends + send emails"] [:br] 45 | [:p "this job runs every 24 hours; clicking this button will add an extra run"] 46 | [:br] [:br] [:br] 47 | [:a.btn {:on-click #(util/fetch "/api/v1/admin/summary" (fn [result] 48 | (pp/pprint result) 49 | (reset! admin-summary* result)))} 50 | "load admin data"] 51 | [:br] [:br] [:br] 52 | (when (not= :loading @admin-summary*) 53 | (map (fn [key] [:details {:open false} [:summary [:b key]] 54 | [:pre "count: " (count (get @admin-summary* key))] 55 | #_[:pre "keys: " (util/preify (map #(or (:request_key %) (:screen_name %)) 56 | (get @admin-summary* key)))] 57 | [:pre {:id key} (util/preify (get @admin-summary* key))]]) 58 | (reverse (sort (keys @admin-summary*)))))])]))) 59 | 60 | #?(:clj 61 | (do 62 | (defn refresh-all-users-friends [current-session log-event worker] 63 | (if-not (is-admin current-session) 64 | (cheshire/generate-string (response/bad-request {:message "you don't have access to this page"})) 65 | 66 | (let [endpoint "/api/v1/admin/refresh_all_users_friends" 67 | message (str "hit endpoint: " endpoint)] 68 | (log-event "worker" {:message message 69 | :endpoint endpoint 70 | :screen-name (:screen-name current-session)}) 71 | (worker) 72 | (cheshire/generate-string message)))) 73 | 74 | (defn summary-data [get-current-session req] 75 | (let [current-session (get-current-session req) 76 | result (if-not (is-admin current-session) 77 | (response/bad-request {:message "you don't have access to this page"}) 78 | {:twitter-profiles (db/select-all db/twitter-profiles-table) 79 | :settings (db/select-all db/settings-table) 80 | :friends (map #(-> % 81 | (assoc :friends-count (count (get-in % [:data "friends"]))) 82 | (dissoc :data)) 83 | (db/select-all db/friends-table)) 84 | :coordinates (db/select-all db/coordinates-table)})] 85 | (cheshire/generate-string result))))) -------------------------------------------------------------------------------- /src/smallworld/util.cljs: -------------------------------------------------------------------------------- 1 | (ns smallworld.util 2 | (:require [clojure.pprint :as pp] 3 | [reagent.core :as r] 4 | [smallworld.decorations :as decorations] 5 | [smallworld.session :as session]) 6 | (:import [goog.async Debouncer])) 7 | 8 | (def debug? false) 9 | 10 | (defn load-stylesheet [href & [callback]] 11 | (let [head (aget (.getElementsByTagName js/document "head") 0) 12 | link (.createElement js/document "link")] 13 | (set! (.-rel link) "stylesheet") 14 | (set! (.-type link) "text/css") 15 | (set! (.-href link) href) 16 | (.appendChild head link) 17 | (when callback (callback)))) 18 | 19 | (defn preify [obj] (with-out-str (pp/pprint obj))) 20 | 21 | (defn fetch [route callback & {:keys [retry?] :or {retry? false}}] 22 | (-> (.fetch js/window route) 23 | (.then #(.json %)) ; parse 24 | (.then #(js->clj % :keywordize-keys true)) ; parse 25 | (.then (fn [result] ; run the callback 26 | (when debug? 27 | (println route ":") 28 | (pp/pprint result)) 29 | (callback result))) 30 | (.catch (fn [error] ; retry 31 | (println (str "error fetching " route ":")) 32 | (js/console.error error) 33 | (when retry? 34 | (println (str "retrying...")) 35 | (fetch route callback)))))) 36 | 37 | ; TODO: combine this with the fetch function 38 | (defn fetch-post [route body & [callback]] 39 | (let [request (new js/Request 40 | route 41 | #js {:method "POST" 42 | :body (.stringify js/JSON (clj->js body)) 43 | :headers (new js/Headers #js {"Content-Type" "application/json"})})] 44 | (-> (js/fetch request) 45 | (.then #(.json %)) 46 | (.then #(js->clj % :keywordize-keys true)) ; parse 47 | (.then (fn [res] 48 | (when debug? (.log js/console res)) 49 | (when callback (callback res))))))) 50 | 51 | (defn exclude-keys [m keys-to-exclude] 52 | (reduce dissoc m keys-to-exclude)) 53 | 54 | (defn debounce [f interval] 55 | (let [dbnc (Debouncer. f interval)] 56 | ;; use apply here to support functions of various arities 57 | (fn [& args] (.apply (.-fire dbnc) dbnc (to-array args))))) 58 | 59 | (defn average [list-of-nums] 60 | (/ (reduce + list-of-nums) 61 | (count list-of-nums))) 62 | 63 | (defn rm-from-list [col idx] 64 | (filter identity (map-indexed #(when-not (= %1 idx) %2) col))) 65 | 66 | (defn error-boundary [& children] 67 | (let [err-state (r/atom nil)] 68 | (r/create-class 69 | {:display-name "error boundary" 70 | :component-did-catch (fn [err info] 71 | (reset! err-state [err info])) 72 | :reagent-render (fn [& children] 73 | (if (nil? @err-state) 74 | (into [:<>] children) 75 | (let [[_ info] @err-state] 76 | [:pre (pr-str info)])))}))) 77 | 78 | (comment 79 | "------------------------------ usage example: -----------------------------" 80 | 81 | (defn my-happy-component [] [:b "This has no error at all, yay!"]) 82 | (defn my-error-component [] (throw (js/Error. "Oops! 👻"))) 83 | 84 | (defn error-boundary-example-1 [] [util/error-boundary [my-error-component]]) 85 | (defn error-boundary-example-2 [] [util/error-boundary [my-happy-component]]) 86 | 87 | [err-bound-example-1] 88 | [err-bound-example-2]) 89 | 90 | (defn exponent [base power] (.pow js/Math base power)) 91 | 92 | (defn query-dom [selector] 93 | (array-seq (.querySelectorAll js/document selector))) 94 | 95 | (defn nav [] 96 | [:div.nav {:class (when (:impersonation? @session/*store) "admin-impersonation")} 97 | [:a#logo-animation.logo {:href "/"} 98 | (decorations/animated-globe) 99 | 100 | [:div.logo-text "small world"]] 101 | [:span.fill-nav-space] 102 | [:a {:href "/settings" #_(rfe/href ::settingks)} 103 | [:b.screen-name " @" (:screen-name @session/*store)]]]) 104 | 105 | (defn device-type [] 106 | (let [ua (.-userAgent js/navigator)] 107 | (cond 108 | (.test #"(?i)(tablet|ipad|playbook|silk)|(android(?!.*mobi))" ua) 109 | "tablet" 110 | (.test #"Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)" ua) 111 | "mobile" 112 | :else nil) 113 | "desktop")) 114 | -------------------------------------------------------------------------------- /src/smallworld/airtable.clj: -------------------------------------------------------------------------------- 1 | (ns smallworld.airtable 2 | "A minimal Clojure client for Airtable.com's HTTP API. 3 | Supports retrieval of whole tables as well as individual records. 4 | Dependencies: [org.clojure/data.json \"0.2.6\"] [clj-http \"2.0.0\"]" 5 | (:require [clj-http.client :as client] 6 | [clojure.data.json :as json] 7 | [clojure.pprint :as pp] 8 | [clojure.set :as set] 9 | [clojure.string :as string] 10 | [clojure.string :as str])) 11 | 12 | (def api-base "https://api.airtable.com/v0") 13 | 14 | (defn build-request-uri-old [base-id resource-path] 15 | ;; (println) 16 | ;; (println) 17 | ;; (print "running `build-request-uri-old` with: ") 18 | ;; (println " base-id: " base-id) 19 | ;; (println " resource-path: " resource-path) 20 | ;; (println) 21 | ;; (println) 22 | (string/join "/" (concat [api-base (name base-id)] (map name resource-path)))) 23 | 24 | (defn build-request-uri [base-id resource-path offset] 25 | (let [base-id (if (keyword? base-id) (str base-id) base-id) 26 | resource-path (map (fn [x] (if (keyword? x) (str x) x)) resource-path) 27 | resource-path (str/join "/" resource-path) 28 | offset (if offset (str "?offset=" offset) "")] 29 | (str api-base "/" base-id "/" resource-path offset))) 30 | 31 | (defn get-in-base* 32 | [{:keys [api-key base-id] :as _base} resource-path & {:keys [offset]}] 33 | (let [req-uri (build-request-uri base-id resource-path offset)] 34 | ;; (println " req-uri: " req-uri) 35 | (client/get req-uri {:headers {"Authorization" (str "Bearer " api-key)}}))) 36 | 37 | (defn kwdize [m] 38 | (set/rename-keys m {"id" :id 39 | "fields" :fields 40 | "createdTime" :created-time})) 41 | 42 | (defn validate-base [{:keys [api-key base-id] :as _base}] 43 | (assert api-key ":api-key must present in passed credentials") 44 | (assert base-id ":base-id must present in passed credentials")) 45 | 46 | (defn validate-resource-path [resource-path] 47 | (assert (sequential? resource-path) "resource-path must be a sequence") 48 | (assert (<= (count resource-path) 2) "resource-path can't have more than two items")) 49 | 50 | (defn get-in-base 51 | "Retrieve tables and records from a table. 52 | `base` needs to be a map containing `:api-key` and `:base-id`. 53 | `resource-path` must be a sequence containing the table name 54 | and an optional record id. 55 | `:base-id` and elements in `resource-path` can be strings or keywords." 56 | [base resource-path & {:keys [offset]}] 57 | (validate-base base) 58 | (validate-resource-path resource-path) 59 | 60 | 61 | (let [data (-> (get-in-base* base resource-path :offset offset) :body json/read-str) 62 | records (map kwdize (get data "records")) 63 | new-offset (get data "offset")] 64 | ;; (println) 65 | ;; (println "running `get-in-base` with:") 66 | ;; (println " offset for this page: " offset) 67 | ;; (println " records in this page: " (count records)) 68 | ;; (println " offset for next page: " new-offset) 69 | (if new-offset 70 | (concat records (get-in-base base resource-path :offset new-offset)) 71 | records))) 72 | 73 | ; only update the fields included in the request, do not overwrite any fields not provided 74 | (defn update-in-base [base resource-path record-id-or-records] 75 | (validate-base base) 76 | (validate-resource-path resource-path) 77 | (let [req-uri (build-request-uri-old (:base-id base) resource-path) 78 | req-body (json/write-str (if (sequential? record-id-or-records) 79 | {:records record-id-or-records} 80 | record-id-or-records))] 81 | ;; (println "") 82 | ;; (pp/pprint "req-body:") 83 | ;; (pp/pprint req-body) 84 | ;; (println "") 85 | ;; (pp/pprint "record-id-or-records:") 86 | ;; (pp/pprint record-id-or-records) 87 | ;; (println "") 88 | (client/patch req-uri {:headers {"Authorization" (str "Bearer " (:api-key base)) 89 | "Content-Type" "application/json"} 90 | :body req-body}))) 91 | 92 | (defn create-in-base [base resource-path record-or-records] 93 | (validate-base base) 94 | (validate-resource-path resource-path) 95 | (let [req-uri (build-request-uri-old (:base-id base) resource-path) 96 | req-body (json/write-str (if (sequential? record-or-records) 97 | {:records record-or-records} 98 | record-or-records))] 99 | (client/post req-uri {:headers {"Authorization" (str "Bearer " (:api-key base)) 100 | "Content-Type" "application/json"} 101 | :body req-body}))) 102 | -------------------------------------------------------------------------------- /src/meetcute/auth_test.clj: -------------------------------------------------------------------------------- 1 | (ns meetcute.auth-test 2 | (:require [clojure.test :refer :all] 3 | [meetcute.auth :as auth]) 4 | (:import (java.util Date))) 5 | 6 | (deftest auth-sessions 7 | (testing "successful authentication with correct code" 8 | (let [email "test@test.com" 9 | code (auth/random-code) 10 | initial-auth-sessions (auth/add-new-code {} email code) 11 | approved-auth-sessions (auth/new-attempt initial-auth-sessions email code)] 12 | (is (nil? (get-in initial-auth-sessions [email :success?]))) 13 | (is (true? (get-in approved-auth-sessions [email :success?]))))) 14 | 15 | (testing "failed authentication with incorrect code" 16 | (let [email "test@test.com" 17 | correct-code (auth/random-code) 18 | wrong-code "000000" 19 | initial-auth-sessions (auth/add-new-code {} email correct-code) 20 | failed-auth-sessions (auth/new-attempt initial-auth-sessions email wrong-code)] 21 | (is (= :error (get-in failed-auth-sessions [email :attempts 0 :result]))) 22 | (is (false? (get-in failed-auth-sessions [email :success?]))))) 23 | 24 | (testing "rate limiting - exceeding maximum attempts per hour" 25 | (let [email "test@test.com" 26 | code (auth/random-code) 27 | initial-auth-sessions (auth/add-new-code {} email code) 28 | ; Create 6 failed attempts 29 | attempts-sessions (reduce 30 | (fn [sessions _] 31 | (auth/new-attempt sessions email "wrong-code")) 32 | initial-auth-sessions 33 | (range 6)) 34 | ; Try one more time with correct code 35 | final-attempt (auth/new-attempt attempts-sessions email code)] 36 | (is (= 6 (count (get-in attempts-sessions [email :attempts])))) 37 | (is (= :error (get-in final-attempt [email :attempts 6 :result]))) 38 | (is (false? (get-in final-attempt [email :success?]))))) 39 | 40 | (testing "nil code attempt" 41 | (let [email "test@test.com" 42 | code (auth/random-code) 43 | initial-auth-sessions (auth/add-new-code {} email code) 44 | failed-auth-sessions (auth/new-attempt initial-auth-sessions email nil)] 45 | (is (= :error (get-in failed-auth-sessions [email :attempts 0 :result]))) 46 | (is (false? (get-in failed-auth-sessions [email :success?]))))) 47 | 48 | (testing "multiple sessions for different emails" 49 | (let [email1 "test1@test.com" 50 | email2 "test2@test.com" 51 | code1 (auth/random-code) 52 | code2 (auth/random-code) 53 | sessions (-> {} 54 | (auth/add-new-code email1 code1) 55 | (auth/add-new-code email2 code2) 56 | (auth/new-attempt email1 code1))] 57 | (is (true? (get-in sessions [email1 :success?]))) 58 | (is (nil? (get-in sessions [email2 :success?]))))) 59 | 60 | (testing "updating existing session with new code" 61 | (let [email "test@test.com" 62 | code1 (auth/random-code) 63 | code2 (auth/random-code) 64 | initial-sessions (auth/add-new-code {} email code1) 65 | updated-sessions (auth/add-new-code initial-sessions email code2)] 66 | (is (= code2 (get-in updated-sessions [email :code]))) 67 | (is (= (get-in initial-sessions [email :started_at]) 68 | (get-in updated-sessions [email :started_at]))))) 69 | 70 | (testing "attempts tracking and last attempt retrieval" 71 | (let [email "test@test.com" 72 | code (auth/random-code) 73 | sessions (-> {} 74 | (auth/add-new-code email code) 75 | (auth/new-attempt email "wrong1") 76 | (auth/new-attempt email "wrong2") 77 | (auth/new-attempt email code)) 78 | attempts (get-in sessions [email :attempts]) 79 | last-try (auth/last-attempt attempts)] 80 | (is (= 3 (count attempts))) 81 | (is (= code (:code last-try))) 82 | (is (= :success (:result last-try))))) 83 | 84 | (testing "time-based attempt filtering" 85 | (let [email "test@test.com" 86 | code (auth/random-code) 87 | old-date #inst "2023-01-01" 88 | recent-date (auth/now) 89 | attempts [{:time old-date :code "wrong" :result :error} 90 | {:time recent-date :code "wrong" :result :error}]] 91 | (is (= 1 (->> attempts 92 | (map :time) 93 | (filter (partial auth/in-last-hour? recent-date)) 94 | count))))) 95 | 96 | (testing "reset functionality" 97 | (let [email "test@test.com" 98 | code (auth/random-code)] 99 | (swap! auth/auth-sessions-state auth/add-new-code email code) 100 | (auth/reset-sms-sessions!) 101 | (is (empty? @auth/auth-sessions-state))))) -------------------------------------------------------------------------------- /src/smallworld/anki_pkg.clj: -------------------------------------------------------------------------------- 1 | (ns smallworld.anki-pkg 2 | (:require [clojure.java.io :as io] 3 | [clojure.string :as str])) 4 | 5 | (import '[java.util.zip ZipFile]) 6 | (import '[java.io File]) 7 | (import '[java.sql DriverManager]) 8 | 9 | (defn extract-zip [zip-file-path dest-dir-path] 10 | (let [zip-file (ZipFile. (File. zip-file-path))] 11 | (doseq [entry (enumeration-seq (.entries zip-file))] 12 | (let [entry-name (.getName entry) 13 | entry-file (File. dest-dir-path entry-name)] 14 | (if (.isDirectory entry) 15 | (.mkdirs entry-file) 16 | (with-open [input-stream (.getInputStream zip-file entry) 17 | output-stream (io/output-stream entry-file)] 18 | (io/copy input-stream output-stream))))))) 19 | 20 | (defn get-connection [db-file-path] 21 | (let [connection (DriverManager/getConnection (str "jdbc:sqlite:" db-file-path))] 22 | connection)) 23 | 24 | #_(defn query-card-count [connection] 25 | (let [statement (.createStatement connection) 26 | resultSet (.executeQuery statement "SELECT count(*) AS card_count FROM cards")] 27 | (when (.next resultSet) 28 | (.getInt resultSet "card_count")))) 29 | 30 | #_(defn print-notes [connection] 31 | (let [query "SELECT * FROM notes LIMIT 2"] 32 | (with-open [stmt (.prepareStatement connection query) 33 | rs (.executeQuery stmt)] 34 | ; iterate through the result rs and print each row 35 | (let [metadata (.getMetaData rs) 36 | column-count (.getColumnCount metadata)] 37 | (loop [] 38 | (when (.next rs) 39 | (doseq [i (range 1 (inc column-count))] 40 | (let [column-name (.getColumnName metadata i) 41 | value (.getString rs i)] 42 | (println (str column-name ": " value)))) 43 | ; pretty print fields 44 | (println "\nFields: " (.getString rs "flds")) 45 | (println) 46 | (recur)))) 47 | #_(loop [] 48 | (when (.next rs) 49 | 50 | #_(let [fields (.getString rs "flds") 51 | [front back] (str/split fields #"\x1f") 52 | media-filename (.getString rs "fname")] 53 | (println (str "Front: " front)) 54 | (println (str "Back: " back)) 55 | (when media-filename 56 | (println (str "Media: " media-filename))) 57 | (recur))))))) 58 | 59 | #_(defn get-flashcards [connection] 60 | (let [query "SELECT * FROM cards LIMIT 10"] 61 | (with-open [stmt (.prepareStatement connection query) 62 | rs (.executeQuery stmt)] 63 | (loop [] 64 | (when (.next rs) 65 | (println rs) 66 | #_(let [fields (.getString rs "flds") 67 | [front back] (str/split fields #"\x1f") 68 | media-filename (.getString rs "fname")] 69 | (println (str "Front: " front)) 70 | (println (str "Back: " back)) 71 | (when media-filename 72 | (println (str "Media: " media-filename))) 73 | (recur))))))) 74 | 75 | (defn print-table-names [connection] 76 | (let [query "SELECT name FROM sqlite_master WHERE type='table';"] 77 | (with-open [stmt (.prepareStatement connection query) 78 | rs (.executeQuery stmt)] 79 | (println "tables:") 80 | (loop [] 81 | (when (.next rs) 82 | (println (str " " (.getString rs "name"))) 83 | (recur)))))) 84 | 85 | (defn print-notes [connection] 86 | (println "print-notes:") 87 | (let [query "SELECT flds FROM notes LIMIT 2"] 88 | (with-open [stmt (.prepareStatement connection query) 89 | rs (.executeQuery stmt)] 90 | (loop [] 91 | (when (.next rs) 92 | (let [fields (.getString rs "flds") 93 | [front back] (clojure.string/split fields #"\x1f")] 94 | (println (str " Front: " front)) 95 | (println (str " Back: " back)) 96 | (println)) 97 | (recur)))))) 98 | 99 | (defn -main [] 100 | (let [file-name "Essential Spanish Vocabulary Top 5000" 101 | parent-dir "/Users/devonzuegel/Downloads/" 102 | zip-file (str parent-dir file-name ".apkg") 103 | dest-dir (str parent-dir file-name)] 104 | (extract-zip zip-file dest-dir) 105 | 106 | (when-not (.exists (File. (str dest-dir "/collection.anki21"))) 107 | (throw ".anki21 file does not exist — sorry, this app currently only supports .anki21 files")) 108 | 109 | (let [connection (get-connection (str dest-dir "/collection.anki21"))] 110 | (println "\n") 111 | (print-notes connection) 112 | (println) 113 | (print-table-names connection) 114 | (println "\n") 115 | 116 | (.close connection)))) 117 | -------------------------------------------------------------------------------- /src/ketchup/routes.clj: -------------------------------------------------------------------------------- 1 | (ns ketchup.routes 2 | (:require [compojure.core :as compo :refer [defroutes GET POST]] 3 | [cheshire.core :as json :refer [generate-string]] 4 | [ketchup.auth :as auth] 5 | [ketchup.db :as db] 6 | [ketchup.env :as env] 7 | [ketchup.notify :as notify] 8 | [ring.middleware.cors :refer [wrap-cors]] 9 | [ring.util.request] 10 | [ring.util.response :as resp])) 11 | 12 | (defn login-or-signup! [{:keys [query-params] :as _req}] 13 | (let [phone (get-in query-params ["phone"]) 14 | smsCode (get-in query-params ["smsCode"]) 15 | user (db/find-or-insert-user! {:phone phone})] 16 | (assert user) 17 | (assert (:id user) (pr-str user)) 18 | (if (= smsCode (env/get-env-var "SMS_CODE")) 19 | {:success true 20 | :message "Login success!" 21 | :authToken (auth/create-auth-token (:id user))} 22 | {:success false 23 | :message "Oops, looks like you don't have the right code!"}))) 24 | 25 | (defroutes open-routes 26 | (POST "/api/v2/login" req 27 | (resp/response (generate-string (login-or-signup! req))))) 28 | 29 | (defn ping [{:keys [params auth/parsed-jwt] :as _req}] 30 | (let [user-id (:user-id parsed-jwt) 31 | status (:status params) 32 | user (db/user-by-id user-id)] 33 | (cond 34 | (nil? status) {:success false :message "status not provided"} 35 | (not (or (= status "online") 36 | (= status "offline"))) {:success false 37 | :message "status must be either 'online' or 'offline'"} 38 | :else (try 39 | (let [result (db/update-user-last-ping! user-id status)] 40 | (println "👾 just pinged by" user-id " · " (str (java.time.Instant/now))) 41 | (println "👾 updated" (count result) "users \n") 42 | ;; only send notification if status has changed 43 | (when-not (= status (:status user)) 44 | (future 45 | (notify/status-change!! user-id status))) 46 | {:success true 47 | :status status 48 | :message "Ping received"}) 49 | (catch Exception e 50 | (println "caught exception when pinging:" e) 51 | {:success false :message "Unknown error"}))))) 52 | 53 | (defn protected-endpoint [{:keys [auth/parsed-jwt] :as _req}] 54 | (if parsed-jwt 55 | {:success true 56 | :message "Success!" 57 | :user (:user-id parsed-jwt)} 58 | {:success false :message "Auth token invalid or expired"})) 59 | 60 | (defn select-user-fields [user] 61 | (select-keys user [:created_at 62 | :name 63 | :screen_name 64 | :email_address 65 | :phone 66 | :email_notifications 67 | :last_ping 68 | :status 69 | :email_address 70 | :updated_at])) 71 | 72 | (defn get-all-users [] 73 | (mapv select-user-fields (db/get-all-users))) 74 | 75 | (defn set-push-token! [{:keys [params auth/parsed-jwt] :as _req}] 76 | (let [user-id (:user-id parsed-jwt) 77 | push_token (:push_token params)] 78 | {:success false :message "push token not provided"} 79 | (db/set-push-token! user-id push_token) 80 | (if (empty? push_token) 81 | (println "push token CLEARED for user" user-id) 82 | (println "push token was SET for user" user-id "to" push_token)) 83 | {:success true :message "push token updated"})) 84 | 85 | ;; Routes under this can only be accessed by authenticated clients 86 | (defroutes authenticated-routes 87 | (GET "/api/v2/protected" req 88 | (resp/response (generate-string (protected-endpoint req)))) 89 | (GET "/api/v2/users" _ 90 | (resp/response (generate-string (get-all-users)))) 91 | (POST "/api/v2/ping" req 92 | (resp/response (generate-string (ping req)))) 93 | (POST "/api/v2/push" req 94 | (resp/response (generate-string (set-push-token! req))))) 95 | 96 | (defn wrap-body-string [handler] 97 | (fn [request] 98 | (let [body-str (ring.util.request/body-string request)] 99 | (handler (assoc request :body (java.io.StringReader. body-str)))))) 100 | 101 | (defn parse-body-params [body] 102 | (json/parse-string body true)) 103 | 104 | (defn wrap-json-params [handler] 105 | (fn [request] 106 | (if-let [params (parse-body-params (slurp (:body request)))] 107 | (handler (update request :params merge params)) 108 | (handler request)))) 109 | 110 | (def app 111 | (-> (compo/routes open-routes 112 | (auth/wrap-authenticated authenticated-routes)) 113 | (wrap-cors :access-control-allow-origin [#".*"] 114 | :access-control-allow-methods [:get :put :post :delete]) 115 | (wrap-json-params) 116 | (wrap-body-string))) -------------------------------------------------------------------------------- /resources/public/privacy-policy.html: -------------------------------------------------------------------------------- 1 | 6 |
7 |

Privacy Policy

8 |

Small World operates the https://small-world-friends.herokuapp.com website, which provides the Service.

9 |

10 | This page is used to inform website visitors regarding our policies with the collection, use, and disclosure of Personal Information if anyone decided to use our Service, the Small World website. 11 |

12 |

13 | If you choose to use our Service, then you agree to the collection and use of information in relation with this policy. The Personal Information that we collect are used for providing and 14 | improving the Service. We will not use or share your information with anyone except as described in this Privacy Policy. 15 |

16 |

Information Collection and Use

17 |

18 | For a better experience while using our Service, we may require you to provide us with certain personally identifiable information, including but not limited to information from your Twitter 19 | profile. The information that we collect will be used to contact or identify you. 20 |

21 |

Log Data

22 |

23 | We want to inform you that whenever you visit our Service, we collect information that your browser sends to us that is called Log Data. This Log Data may include information such as your 24 | computer’s Internet Protocol ("IP") address, browser version, pages of our Service that you visit, the time and date of your visit, the time spent on those pages, and other statistics. 25 |

26 |

Cookies

27 |

28 | Cookies are files with small amount of data that is commonly used an anonymous unique identifier. These are sent to your browser from the website that you visit and are stored on your computer’s 29 | hard drive. 30 |

31 |

32 | Our website uses these "cookies" to collection information and to improve our Service. You have the option to either accept or refuse these cookies, and know when a cookie is being sent 33 | to your computer. If you choose to refuse our cookies, you may not be able to use some portions of our Service. 34 |

35 |

Service Providers

36 |

We may employ third-party companies and individuals due to the following reasons:

37 | 43 |

44 | We want to inform our Service users that these third parties have access to your Personal Information. The reason is to perform the tasks assigned to them on our behalf. However, they are 45 | obligated not to disclose or use the information for any other purpose. 46 |

47 |

Security

48 |

49 | We value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the 50 | internet, or method of electronic storage is 100% secure and reliable, and we cannot guarantee its absolute security. 51 |

52 | 53 |

54 | Our Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by us. Therefore, we strongly 55 | advise you to review the Privacy Policy of these websites. We have no control over, and assume no responsibility for the content, privacy policies, or practices of any third-party sites or 56 | services. 57 |

58 |

Children's Privacy

59 |

60 | Our Services do not address anyone under the age of 13. We do not knowingly collect personal identifiable information from children under 13. In the case we discover that a child under 13 has 61 | provided us with personal information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, 62 | please contact us so that we will be able to do necessary actions. 63 |

64 |

Changes to This Privacy Policy

65 |

66 | We may update our Privacy Policy from time to time. Thus, we advise you to review this page periodically for any changes. We will notify you of any changes by posting the new Privacy Policy on 67 | this page. These changes are effective immediately, after they are posted on this page. 68 |

69 |

Contact Us

70 |

71 | If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us: https://github.com/devonzuegel/smallworld 72 |

73 |
74 | -------------------------------------------------------------------------------- /src/smallworld/coordinates.clj: -------------------------------------------------------------------------------- 1 | (ns smallworld.coordinates (:require [clojure.data.json :as json] 2 | [smallworld.util :as util] 3 | [smallworld.memoize :as m] 4 | [smallworld.db :as db])) 5 | 6 | (def debug? false) 7 | 8 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 9 | ;; coordinate data fetching ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 10 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 11 | 12 | ;; if you need to make the locations more precise in the future, use the bbox 13 | ;; (bounding box) rather than just the coordinates 14 | (defn extract-coordinates [raw-result] 15 | (let [result (json/read-str raw-result) 16 | status-code (get result "statusCode") 17 | coordinates (get-in result ["resourceSets" 0 "resources" 0 "geocodePoints" 0 "coordinates"]) 18 | coordinates {:lat (first coordinates) :lng (second coordinates)}] 19 | (if (= 200 status-code) coordinates 20 | (print "houston, we have a problem...")))) 21 | 22 | (defn get-from-city [city-str] 23 | (if (or (empty? city-str) (nil? city-str)) 24 | (do (when debug? (println "city-str:" city-str)) 25 | nil ; return nil coordinates if no city string is given TODO: return :no-result 26 | ) 27 | (try 28 | (let [city (java.net.URLEncoder/encode (or city-str "") "UTF-8") ;; the (if empty?) shouldn't caught the nil string thing... not sure why it didn't 29 | api-key (java.net.URLEncoder/encode (util/get-env-var "BING_MAPS_API_KEY") "UTF-8")] 30 | (when debug? 31 | (println "city:" city) 32 | (println "api-key:" api-key) 33 | (println "")) 34 | (-> (str "https://dev.virtualearth.net/REST/v1/Locations/" city "?key=" api-key) 35 | slurp 36 | extract-coordinates)) 37 | (catch Throwable e 38 | (println "\nBing Maps API - returning nil, because API call failed to retrieve a valid result") 39 | nil)))) 40 | 41 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 42 | ;; calculations ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 43 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 44 | 45 | (defn haversine [{lon1 :lng lat1 :lat} {lon2 :lng lat2 :lat}] 46 | ; Haversine formula 47 | ; a = sin²(Δφ/2) + cos φ1 ⋅ cos φ2 ⋅ sin²(Δλ/2) 48 | ; c = 2 ⋅ atan2( √a, √(1−a) ) 49 | ; d = R ⋅ c 50 | ; where φ is latitude, λ is longitude, R is earth’s radius (mean radius = 6,371km); 51 | ; "Implementation of Haversine formula. Takes two sets of latitude/longitude pairs and returns the shortest great circle distance between them (in km)" 52 | (assert (every? some? [lon1 lat1 lon2 lat2]) "coordinates {:lat, :lng} must not be nil") 53 | (let [R 6378.137 ; radius of Earth in km 54 | dlat (Math/toRadians (- lat2 lat1)) 55 | dlon (Math/toRadians (- lon2 lon1)) 56 | lat1 (Math/toRadians lat1) 57 | lat2 (Math/toRadians lat2) 58 | a (+ (* (Math/sin (/ dlat 2)) (Math/sin (/ dlat 2))) 59 | (* (Math/sin (/ dlon 2)) (Math/sin (/ dlon 2)) (Math/cos lat1) (Math/cos lat2)))] 60 | (* R 2 (Math/asin (Math/sqrt a))))) 61 | 62 | (defn coordinates-not-defined? [coords] 63 | (or (nil? coords) 64 | (some nil? (vals coords)))) 65 | 66 | (defn distance-btwn [coords1 coords2] 67 | (when debug? 68 | (println "coords1:" coords1) 69 | (println "coords2:" coords2) 70 | (println "")) 71 | (if (or (coordinates-not-defined? coords1) 72 | (coordinates-not-defined? coords2)) 73 | nil 74 | (haversine coords1 coords2))) 75 | 76 | (defn tokenize [s] (set (re-seq #"\w+" (clojure.string/lower-case s)))) 77 | 78 | (defn jaccard [s1 s2] 79 | (/ (count (clojure.set/intersection (tokenize s1) (tokenize s2))) 80 | (count (clojure.set/union (tokenize s1) (tokenize s2))))) 81 | 82 | (defn very-similar-location-names [pairs] 83 | ; Don't consider them very similar if there is a slash or ampersand in either 84 | ; of the names, because that means there might be two locations in the string. 85 | (let [result (if (or (re-find #"/" (first pairs)) 86 | (re-find #"/" (second pairs)) 87 | (re-find #"&" (first pairs)) 88 | (re-find #"&" (second pairs))) 89 | false 90 | (> (apply jaccard pairs) 0.5))] 91 | (println " pairs:" pairs) 92 | (println "very-similar-location-names?" result) 93 | (println " jaccard:" (apply jaccard pairs)) 94 | (println "") 95 | result)) 96 | 97 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 98 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 99 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 100 | 101 | 102 | (def table-memoized (m/my-memoize get-from-city db/coordinates-table)) 103 | (def atom-memoized (m/my-memoize table-memoized (atom {}))) 104 | (def memoized atom-memoized) ; re-naming just for export 105 | -------------------------------------------------------------------------------- /src/meetcute/util.cljc: -------------------------------------------------------------------------------- 1 | (ns meetcute.util 2 | (:require [clojure.string :as str] 3 | [clojure.walk :refer [keywordize-keys]])) 4 | 5 | (defn remove-nth [lst n] 6 | (println "remove-nth: " n) 7 | (concat (take n lst) (drop (inc n) lst))) 8 | 9 | (defn clean-email 10 | [email] 11 | (str/lower-case (str/trim email))) 12 | 13 | (defn valid-email? [email] 14 | (and (string? email) 15 | (not (empty? email)))) 16 | 17 | (defn valid-code? [code] 18 | (and (string? code) 19 | (re-matches #"^\d{6}$" code))) 20 | 21 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 22 | ;; phone utils 23 | 24 | (defn clean-phone 25 | "The standard format is +{AREA}{NUMBER} without separators. Examples: 26 | 27 | +14159499931 28 | +16507919090 29 | +5491137560419" 30 | [phone] 31 | (let [only-numbers (some-> phone (str/replace #"[^0-9]" ""))] 32 | (some->> only-numbers (str "+")))) 33 | 34 | ;; TODO: use a proper validation function 35 | (defn valid-phone? 36 | "Strips the phone number of all non-numeric characters, then check if it's a valid phone number. " 37 | [phone] 38 | (let [phone (or (some-> phone clean-phone) "")] 39 | (and (not-empty phone) 40 | (<= 9 (count phone))))) 41 | 42 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 43 | ;; profile utils 44 | 45 | (defn get-field [bio field] 46 | (get-in bio [(keyword field)])) 47 | 48 | (defn get-gender-filter [bio] 49 | (case (get-field bio "I'm interested in...") 50 | [] [] 51 | ["Women"] ["Woman"] 52 | ["Men"] ["Man"] 53 | ["Woman" "Man"] ; default 54 | )) 55 | 56 | (defn included-bios [profile cuties] 57 | (let [profile (keywordize-keys profile) 58 | cuties (keywordize-keys cuties) 59 | matches-preferences? (fn [cutie] 60 | ;; (println) 61 | ;; (println (get-field profile "First name") " gets to meet " (get-field cutie "First name")) 62 | ;; (println "Include in gallery? " (get-field cutie "Include in gallery?")) 63 | ;; (println " " (= (get-field cutie "Include in gallery?") "include in gallery")) 64 | 65 | ;; (println "passes the filter? " (and (= (get-field cutie "Include in gallery?") "include in gallery") ; don't include bios that have been explicitly excluded 66 | ;; (not (= (:id cutie) (:id profile))) ; check the cutie is not themself 67 | ;; (some #(= (:Gender cutie) %) (get-gender-filter profile)) ; only show the gender that the user is interested in dating 68 | ;; (some #(= (:Gender profile) %) (get-gender-filter cutie)) ; only show someone if they're interested in dating someone of the gender of the current user: 69 | ;; )) 70 | 71 | (and (= (get-field cutie "Include in gallery?") "include in gallery") ; don't include bios that have been explicitly excluded 72 | (not (= (:id cutie) (:id profile))) ; check the cutie is not themself 73 | (some #(= (:Gender cutie) %) (get-gender-filter profile)) ; only show the gender that the user is interested in dating 74 | (some #(= (:Gender profile) %) (get-gender-filter cutie)) ; only show someone if they're interested in dating someone of the gender of the current user: 75 | ))] 76 | (filter matches-preferences? cuties))) 77 | 78 | (def fields-changeable-by-user [; Phone is intentionally not included because it's used as the key to find the record to update, so we don't want to overwrite it 79 | "Anything else you'd like your potential matches to know?" 80 | "Social media links" 81 | "Email" 82 | "First name" 83 | "Last name" 84 | "Phone" 85 | "include-in-gallery?" 86 | "Home base city" 87 | "Other cities where you spend time" 88 | "locations-json" 89 | "I'm interested in..." 90 | "If 'Other', who invited you?" 91 | "Birthday" 92 | "unseen-cuties" 93 | "todays-cutie" 94 | "selected-cuties" 95 | "rejected-cuties" 96 | "What makes this person awesome?" 97 | "Pictures" 98 | "Gender"]) 99 | 100 | (defn in? [list str] (some #(= str %) list)) 101 | -------------------------------------------------------------------------------- /src/meetcute/routes.clj: -------------------------------------------------------------------------------- 1 | (ns meetcute.routes 2 | (:require [compojure.core :as compo :refer [defroutes GET POST ANY]] 3 | [compojure.route :as route] 4 | [clojure.java.io :as io] 5 | [clojure.string :as str] 6 | [cheshire.core :as json] 7 | [meetcute.logic :as logic] 8 | [smallworld.util :as sw-util] 9 | [meetcute.auth :as mc.auth] 10 | [meetcute.util :as mc.util] 11 | [ring.util.mime-type :as mime] 12 | [ring.util.request] 13 | [ring.util.response :as resp])) 14 | 15 | (defn parse-body-params [body] 16 | (json/parse-string body true)) 17 | 18 | (defn- add-wildcard [^String path] 19 | (str path (if (.endsWith path "/") "*" "/*"))) 20 | 21 | (defn- add-mime-type [response path options] 22 | (if-let [mime-type (mime/ext-mime-type path (:mime-types options {}))] 23 | (resp/content-type response mime-type) 24 | response)) 25 | 26 | (defn resources 27 | ([path] 28 | (resources path {})) 29 | ([path options] 30 | (GET (add-wildcard path) {{resource-path :*} :route-params} 31 | (let [resource-path (str/replace resource-path (re-pattern "^/meetcute/") ; only the first occurrence, anchored to the beginning of the string 32 | "") 33 | root "public"] 34 | (some-> (resp/resource-response (str root "/" resource-path)) 35 | (add-mime-type resource-path options)))))) 36 | 37 | #_(defn tmp-upload-handler [request] 38 | (if-let [file (-> request :params :file)] 39 | (let [phone (some-> (mc.auth/req->parsed-jwt request) :auth/phone mc.util/clean-phone) 40 | cutie (logic/my-profile phone :force-refresh? true)] 41 | (println "file: " file) 42 | (println "filename: " (:filename file)) 43 | (println "cutie id: " (:id cutie)) 44 | (println " phone: " phone) 45 | (io/copy (:tempfile file) 46 | (io/file (str "resources/public/tmp-img-uploads/" (:filename file)))) 47 | (logic/update-cutie-picture (:id cutie) 48 | (str "https://7138-186-177-83-218.ngrok-free.app/tmp-img-uploads/" (:filename file))) 49 | (resp/redirect "/meetcute/settings")) 50 | (resp/response "No file provided"))) 51 | 52 | (defn tmp-file-path [file] 53 | (if (= (:prod sw-util/ENVIRONMENTS) (sw-util/get-env-var "ENVIRONMENT")) 54 | (str "https://smallworld.kiwi/tmp/" (:filename file)) 55 | (do 56 | (println " you are using ngrok to upload files. Have you changed the ngrok URL? ") 57 | (str " https://b15f-137-103-250-209.ngrok-free.app/tmp/" (:filename file))))) 58 | 59 | (defn tmp-upload-handler [request] 60 | (try 61 | (let [files (-> request :params :file) 62 | ; make sure files is a list, even if we're just given one file. make it a seq: 63 | files (if (map? files) (list files) files) 64 | files (->> files 65 | (map (fn [file] 66 | (if-let [extension (last (str/split (:filename file) #"\."))] 67 | (assoc file :filename (str (random-uuid) "." extension)) 68 | (assoc file :filename (str (random-uuid)))))))] 69 | (println "files: ") 70 | (println files) 71 | (if (seq files) 72 | (let [parsed-jwt (mc.auth/req->parsed-jwt request) 73 | cutie (logic/my-profile parsed-jwt :force-refresh? true)] 74 | (println "cutie id: " (:id cutie)) 75 | 76 | ; for each file, copy it to the tmp-img-uploads directory 77 | (doseq [file files] 78 | (println "file: " file) 79 | (println "filename: " (:filename file)) 80 | (println "") 81 | (io/copy (:tempfile file) 82 | (io/file (str "/tmp/" (:filename file))))) 83 | 84 | ; add all files to the cutie's airtable record 85 | (logic/add-pictures-to-cutie-airtable (:id cutie) (map #(tmp-file-path %) 86 | files)) 87 | (resp/redirect "/meetcute/settings")) 88 | (resp/response "No file provided"))) 89 | 90 | (catch Exception _e 91 | (resp/response "Error while processing file upload.")))) 92 | 93 | 94 | (defroutes open-routes 95 | (ANY "/" [] (io/resource "public/meetcute.html")) 96 | (ANY "/admin" [] (io/resource "public/meetcute.html")) 97 | (ANY "/settings" [] (io/resource "public/meetcute.html")) 98 | (GET "/signup" req (mc.auth/signup-route req)) 99 | (GET "/signin" req (mc.auth/signin-route req)) 100 | (POST "/signup" req (mc.auth/start-signup-route req)) 101 | (POST "/signin" req (mc.auth/start-signin-route req)) 102 | (POST "/verify-signup" req (mc.auth/verify-signup-route req)) 103 | (POST "/verify" req (mc.auth/verify-route req)) 104 | (POST "/logout" req (mc.auth/logout-route req))) 105 | 106 | ;; Routes under this can only be accessed by authenticated clients 107 | (defroutes authenticated-routes 108 | (GET "/api/matchmaking/bios" req (json/generate-string (logic/get-needed-bios req))) 109 | (POST "/api/matchmaking/profile" req (logic/update-profile req)) 110 | (POST "/tmp-upload" req (tmp-upload-handler req)) 111 | (ANY "/api/echo" req (resp/response (pr-str req))) 112 | (POST "/api/matchmaking/me" req (let [parsed-jwt (mc.auth/req->parsed-jwt req)] 113 | (assert parsed-jwt) 114 | (json/generate-string {:fields (logic/my-profile parsed-jwt)}))) 115 | (GET "/api/get-airtable-db-name" _ (json/generate-string (logic/get-airtable-db-name))) 116 | (POST "/api/admin/update-airtable-db" req (logic/update-airtable-db req)) 117 | (POST "/api/refresh-todays-cutie" req (let [parsed-body (:params req) 118 | id (:id parsed-body)] 119 | (logic/refresh-todays-cutie-from-id id))) 120 | (POST "/api/refresh-todays-cutie/mine" req (logic/refresh-todays-cutie-route-mine req)) 121 | (POST "/api/refresh-todays-cutie/all" req (logic/refresh-todays-cutie-route-all req))) 122 | 123 | (defn wrap-body-string [handler] 124 | (fn [request] 125 | (let [body-str (ring.util.request/body-string request)] 126 | (handler (assoc request :body (java.io.StringReader. body-str)))))) 127 | 128 | (defn wrap-json-params [handler] 129 | (fn [request] 130 | (if-let [params (parse-body-params (slurp (:body request)))] 131 | (handler (update request :params merge params)) 132 | (handler request)))) 133 | 134 | (def app 135 | (-> (compo/routes 136 | open-routes 137 | (resources "/") 138 | (mc.auth/wrap-authenticated authenticated-routes)) 139 | (wrap-json-params) 140 | (wrap-body-string))) 141 | 142 | -------------------------------------------------------------------------------- /src/smallworld/mocks.cljc: -------------------------------------------------------------------------------- 1 | (ns smallworld.mocks) 2 | 3 | ; TODO: find out how to only load this in development, not in production 4 | ; use during development when you don't want to fetch from Twitter 5 | 6 | #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} 7 | (def current-user 8 | {:profile_image_url_large 9 | "https://pbs.twimg.com/profile_images/1410680490949058566/lIlsTIH6.jpg" 10 | :main-coords {:lat 25.792236328125 11 | :lng -80.13484954833984} 12 | :name-location nil 13 | :name "Hardcoded ☀️" 14 | :user-id "TODO" 15 | :screen-name "hardcoded" 16 | :main-location "Miami Beach" 17 | :name-coords nil 18 | :distance 19 | {:name-main nil 20 | :name-name nil 21 | :main-main nil 22 | :main-name nil}}) 23 | 24 | (def friend 25 | {:profile_image_url_large 26 | "https://pbs.twimg.com/profile_images/1421550750426140672/FrfugU7f.png" 27 | :main-coords nil 28 | :name-location nil 29 | :name "friend" 30 | :user-id "TODO" 31 | :screen-name "_nakst" 32 | :main-location "Miami" 33 | :name-coords nil 34 | :distance 35 | {:name-main nil 36 | :name-name 2 37 | :main-main nil 38 | :main-name nil}}) 39 | 40 | #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} 41 | (def friends [friend 42 | friend 43 | friend 44 | friend]) 45 | 46 | (def settings {:friends_refresh nil, 47 | :twitter_last_fetched #inst "2023-06-24T21:11:52.374651000-00:00", 48 | :name "myscreenname", 49 | :screen_name "myscreenname", 50 | :main_location_corrected nil, 51 | :updated_at #inst "2023-06-24T21:11:52.374651000-00:00", 52 | :locations [{:name "South Florida ☀️", 53 | :coords {:lat 25.719772338867188, :lng -80.4179916381836}, 54 | :distances nil, 55 | :special-status "twitter-location", 56 | :name-initial-value "South Florida ☀️"} 57 | {:name "Sonoma County", 58 | :coords {:lat 38.43969727, :lng -122.71564484}, 59 | :loading false, 60 | :special-status "added-manually"} 61 | {:name "Santa Rosa", 62 | :coords {:lat 38.43969727, :lng -122.71564484}, 63 | :loading false, 64 | :special-status "added-manually"} 65 | {:name "Windsor, CA", 66 | :coords {:lat 38.54728317, :lng -122.81663513}, 67 | :loading false, 68 | :special-status "added-manually"}] 69 | :id 217, 70 | :welcome_flow_complete true, 71 | :name_location_corrected nil, 72 | :email_address "example@gmail.com", 73 | :email_notifications "daily", 74 | :twitter_avatar 75 | "https://pbs.twimg.com/profile_images/1117495906335485952/waXUR3aO.jpg", 76 | :created_at #inst "2023-06-24T02:39:56.647212000-00:00"}) 77 | 78 | (def raw-twitter-friend 79 | {:screen-name "_kasita" 80 | :location "Austin, TX" 81 | :description "Hospitality for the Modern Traveler" 82 | :default-profile-image false 83 | :follow-request-sent false 84 | :friends-count 68 85 | :withheld-in-countries [] 86 | :profile-background-image-url 87 | "http://abs.twimg.com/images/themes/theme1/bg.png" 88 | :is-translator false 89 | :utc-offset nil 90 | :name "Kasita" 91 | :profile-sidebar-fill-color "DDEEF6" 92 | :profile-sidebar-border-color "C0DEED" 93 | :statuses-count 912 94 | :entities 95 | {:url 96 | {:urls 97 | [{:url "https://t.co/m86n6JOpCR" 98 | :expanded-url "http://www.kasita.com" 99 | :display-url "kasita.com" 100 | :indices [0 23]}]} 101 | :description {:urls []}} 102 | :id-str "3661383132" 103 | :following true 104 | :profile-background-color "C0DEED" 105 | :lang nil 106 | :live-following false 107 | :profile-background-image-url-https 108 | "https://abs.twimg.com/images/themes/theme1/bg.png" 109 | :followers-count 2054 110 | :profile-background-tile false 111 | :notifications false 112 | :is-translation-enabled false 113 | :translator-type "none" 114 | :status 115 | {:retweet-count 0 116 | :in-reply-to-user-id nil 117 | :coordinates nil 118 | :in-reply-to-user-id-str nil 119 | :place nil 120 | :geo nil 121 | :entities 122 | {:hashtags [] 123 | :symbols [] 124 | :user-mentions [] 125 | :urls [] 126 | :media 127 | [{:sizes 128 | {:medium {:w 1200, :h 1200, :resize "fit"} 129 | :thumb {:w 150, :h 150, :resize "crop"} 130 | :large {:w 1800, :h 1800, :resize "fit"} 131 | :small {:w 680, :h 680, :resize "fit"}} 132 | :display-url "pic.twitter.com/NZgHvAQyJv" 133 | :expanded-url 134 | "https://twitter.com/_kasita/status/1376323397513269255/photo/1" 135 | :type "photo" 136 | :id-str "1376323237597024260" 137 | :id 1376323237597024260 138 | :url "https://t.co/NZgHvAQyJv" 139 | :indices [39 62] 140 | :media-url-https 141 | "https://pbs.twimg.com/media/ExmusUkWEAQKACD.jpg" 142 | :media-url "http://pbs.twimg.com/media/ExmusUkWEAQKACD.jpg"}]} 143 | :id-str "1376323397513269255" 144 | :in-reply-to-status-id nil 145 | :source "LaterMedia" 146 | :extended-entities 147 | {:media 148 | [{:sizes 149 | {:medium {:w 1200, :h 1200, :resize "fit"} 150 | :thumb {:w 150, :h 150, :resize "crop"} 151 | :large {:w 1800, :h 1800, :resize "fit"} 152 | :small {:w 680, :h 680, :resize "fit"}} 153 | :display-url "pic.twitter.com/NZgHvAQyJv" 154 | :expanded-url 155 | "https://twitter.com/_kasita/status/1376323397513269255/photo/1" 156 | :type "photo" 157 | :id-str "1376323237597024260" 158 | :id 1376323237597024260 159 | :url "https://t.co/NZgHvAQyJv" 160 | :indices [39 62] 161 | :media-url-https 162 | "https://pbs.twimg.com/media/ExmusUkWEAQKACD.jpg" 163 | :media-url "http://pbs.twimg.com/media/ExmusUkWEAQKACD.jpg"}]} 164 | :lang "en" 165 | :possibly-sensitive false 166 | :id 1376323397513269255 167 | :contributors nil 168 | :truncated false 169 | :retweeted false 170 | :in-reply-to-screen-name nil 171 | :is-quote-status false 172 | :in-reply-to-status-id-str nil 173 | :favorited false 174 | :created-at "Mon Mar 29 00:00:43 +0000 2021" 175 | :favorite-count 1 176 | :text 177 | "Hospitality Redefined. Coming in 2021. https://t.co/NZgHvAQyJv"} 178 | :id 3661383132 179 | :url "https://t.co/m86n6JOpCR" 180 | :profile-use-background-image true 181 | :protected false 182 | :listed-count 60 183 | :muting false 184 | :profile-link-color "1DA1F2" 185 | :geo-enabled true 186 | :has-extended-profile false 187 | :favourites-count 847 188 | :profile-banner-url 189 | "https://pbs.twimg.com/profile_banners/3661383132/1563572069" 190 | :created-at "Wed Sep 23 16:47:58 +0000 2015" 191 | :profile-image-url-https 192 | "https://pbs.twimg.com/profile_images/1348709965150748672/FcD5yO9__normal.jpg" 193 | :contributors-enabled false 194 | :profile-image-url 195 | "http://pbs.twimg.com/profile_images/1348709965150748672/FcD5yO9__normal.jpg" 196 | :profile-text-color "333333" 197 | :blocking false 198 | :default-profile true 199 | :verified false 200 | :time-zone nil 201 | :blocked-by false}) -------------------------------------------------------------------------------- /src/smallworld/frontend.cljs: -------------------------------------------------------------------------------- 1 | 2 | (ns smallworld.frontend 3 | (:require [reagent.core :as r] 4 | [reitit.frontend :as rf] 5 | [reitit.frontend.easy :as rfe] 6 | [reitit.frontend.controllers :as rfc] 7 | [reitit.ring :as ring] 8 | [reitit.coercion.schema :as rsc] 9 | [schema.core :as s] 10 | [clojure.test :refer [deftest is]] 11 | [fipp.edn :as fedn] 12 | [smallworld.session :as session] 13 | [smallworld.decorations :as decorations] 14 | [smallworld.screens.settings :as settings] 15 | [smallworld.util :as util] 16 | [smallworld.screens.home :as home] 17 | [smallworld.screens.meetcute :as meetcute] 18 | [clojure.pprint :as pp] 19 | [cljsjs.mapbox] 20 | [goog.dom] 21 | [smallworld.admin :as admin])) 22 | 23 | (def *debug? (r/atom false)) 24 | 25 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 26 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 27 | 28 | (util/fetch "/api/v1/settings" (fn [result] 29 | (when @*debug? 30 | (println "/api/v1/settings:") 31 | (pp/pprint result)) 32 | (reset! settings/*settings result) 33 | (reset! settings/*email-address (:email_address result)))) 34 | 35 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 36 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 37 | 38 | (defn signin-page [] 39 | [:div.welcome 40 | [:div.hero 41 | [:p.serif {:style {:font-size "1.5em" :margin-top "8px" :margin-bottom "4px"}} 42 | "welcome to"] 43 | [:h1 {:style {:font-weight "bold" :font-size "2.6em"}} 44 | "Small World"] 45 | [:div#logo-animation.logo (decorations/animated-globe)] 46 | [:h2 47 | [:a#login-btn {:href "login"} (decorations/twitter-icon) "log in with Twitter"]]] 48 | [:div.steps 49 | [:p [:b "step 1:"] " log in with Twitter"] 50 | [:p [:b "step 2:"] " update what city you're in"] 51 | [:p [:b "step 3:"] " see a map of who's nearby"]] 52 | [:div.info 53 | [:p "Small World uses the location from your" [:br] 54 | [:a {:href "https://twitter.com/settings/profile" :target "_blank"} 55 | "Twitter profile"] " to find nearby friends"]] 56 | #_[:div.faq 57 | [:div.question 58 | [:p [:b "Q: how does small world work?"]] 59 | [:p "small world checks to see if the people you follow on Twitter have updated their location. it looks at two places:"] 60 | [:ul 61 | [:li "their display name, which small world interprets as they're traveling to that location"] 62 | [:li "their location, which small world interprets as they're living in that location"]]] 63 | 64 | [:hr] 65 | 66 | [:div.question 67 | [:p [:b "Q: why isn't my friend showing up"]] 68 | [:p "they may not have their location set on Twitter (either in their name or in the location field), or small world may not be able to parse the location yet."] 69 | [:p "if they have their location set but it's just not showing up in the app, please " [:a {:href "https://github.com/devonzuegel/smallworld"} "open a GitHub issue"] " and share more so I can improve the city parser."]]]]) 70 | 71 | (defn home-page [] 72 | (if (:welcome_flow_complete @settings/*settings) 73 | [home/screen] 74 | [settings/welcome-flow-screen])) 75 | 76 | (defn not-found-404-page [] 77 | [:p {:style {:top "calc(30%)" 78 | :position "fixed" 79 | :text-align "center" 80 | :width "20em" 81 | :left "calc(50% - 10em)" 82 | :font-size "2em"}} 83 | "404 not found"]) 84 | 85 | (defonce match (r/atom nil)) 86 | 87 | (defn redirect! [path] 88 | (.replace (.-location js/window) path)) 89 | 90 | (defn current-page [] ; TODO: handle logged out state 91 | (if (= :loading @session/*store) 92 | 93 | ; TODO: this is really hacky, but at some point we'll separate smallworld and meetcute 94 | (if (some? (re-find #"meetcute" (.-pathname (.-location js/window)))) 95 | [meetcute/loading-page] ; TODO: meetcute doesn't even need the @session/*store, so we simply not even wait for it to load in the future; but for now, we'll just use the loading screen rather than ripping out the session stuff 96 | (decorations/loading-screen) ; smallworld 97 | ) 98 | 99 | (if (nil? @match) 100 | not-found-404-page 101 | (let [view (:view (:data @match))] 102 | [view @match])))) 103 | 104 | (defn if-session-loading [next-steps-fn] 105 | #(if (= :loading @session/*store) 106 | (util/fetch "/api/v1/session" next-steps-fn) 107 | (next-steps-fn @session/*store))) 108 | 109 | (def require-blank-session 110 | [{:start (if-session-loading #(if (empty? %) 111 | (session/update! %) 112 | (redirect! "/")))}]) 113 | 114 | (def require-session 115 | [{:start (if-session-loading #(if (empty? %) 116 | (redirect! "/signin") 117 | (session/update! %)))}]) 118 | 119 | (def require-blank-profile 120 | [{:start #(do 121 | ;; (pp/pprint "@meetcute/profile") 122 | ;; (pp/pprint @meetcute/profile) 123 | (when-not (empty? @meetcute/profile) 124 | (redirect! "meetcute"))) 125 | ; 126 | }]) 127 | 128 | (def require-admin 129 | [{:start (if-session-loading #(when (not (admin/is-admin %)) 130 | (redirect! "/not-found")))}]) 131 | 132 | (def routes 133 | (rf/router 134 | ["/" 135 | ; authentication for meetcute is handled by the server in `meetcute/routes.clj` 136 | ["meetcute/admin" {:name ::meetcute-admin :view (meetcute/screen :admin)}] 137 | ["meetcute/settings" {:name ::meetcute-me :view (meetcute/screen :profile)}] 138 | ["meetcute" {:name ::meetcute :view (meetcute/screen :home)}] 139 | 140 | ; authentication for smallworld is handled by the frontend via the controllers: 141 | ["" {:name ::home :view home-page :controllers require-session}] 142 | ["signin" {:name ::signin :view signin-page :controllers require-blank-session}] 143 | ["settings" {:name ::settings :view settings/screen :controllers require-session}] 144 | ["admin" {:name ::admin :view admin/screen :controllers require-admin}]] 145 | {:data {:coercion rsc/coercion}})) 146 | 147 | (deftest test-routes ; note – this will not get run at the same time as the clj tests 148 | (is (= (rf/match-by-path routes "/no-match") nil)) 149 | (is (not= (rf/match-by-path routes "/settings") nil)) 150 | (is (not= (rf/match-by-path routes "/settings/") nil))) 151 | 152 | (defn init! [] 153 | (rfe/start! 154 | routes 155 | (fn [new-match] 156 | (swap! match (fn [old-match] 157 | (when new-match 158 | (assoc new-match :controllers (rfc/apply-controllers (:controllers old-match) new-match))))) 159 | (util/fetch "/api/v1/session" session/update!) ; TODO: this is only for small world; remove it for meetcute routes 160 | ) 161 | {:use-fragment false}) 162 | (r/render [current-page] (.getElementById js/document "app"))) 163 | 164 | (init!) -------------------------------------------------------------------------------- /src/smallworld/decorations.cljs: -------------------------------------------------------------------------------- 1 | (ns smallworld.decorations 2 | (:require [goog.dom] 3 | [reagent.core :as r] 4 | [goog.dom.classlist :as gc])) 5 | 6 | (def debug? false) 7 | 8 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 9 | ;; icons ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 10 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 11 | 12 | (defn twitter-icon [] 13 | [:svg.twitter-icon {:viewBox "0 0 24 24"} 14 | [:path {:d "M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"}]]) 15 | 16 | (defn edit-icon [] 17 | [:svg.edit-icon {:viewBox "0 0 24 24"} 18 | [:path {:d "M14.078 4.232l-12.64 12.639-1.438 7.129 7.127-1.438 12.641-12.64-5.69-5.69zm-10.369 14.893l-.85-.85 11.141-11.125.849.849-11.14 11.126zm2.008 2.008l-.85-.85 11.141-11.125.85.85-11.141 11.125zm18.283-15.444l-2.816 2.818-5.691-5.691 2.816-2.816 5.691 5.689z"}]]) 19 | 20 | (defn info-icon [] 21 | [:svg {:class "info" :view-box "0 0 16 16" :width "16px" :height "16px"} 22 | [:g [:path {:style {:transform "scale(0.25)"} :d "m32 2c-16.568 0-30 13.432-30 30s13.432 30 30 30 30-13.432 30-30-13.432-30-30-30m5 49.75h-10v-24h10v24m-5-29.5c-2.761 0-5-2.238-5-5s2.239-5 5-5c2.762 0 5 2.238 5 5s-2.238 5-5 5"}]]]) 23 | 24 | (defn question-icon [] 25 | [:svg {:class "question" :view-box "0 0 18 18" :width "22px" :height "22px"} 26 | [:g [:path {:style {:transform "scale(.75)"} :d "M14.601 21.5c0 1.38-1.116 2.5-2.499 2.5-1.378 0-2.499-1.12-2.499-2.5s1.121-2.5 2.499-2.5c1.383 0 2.499 1.119 2.499 2.5zm-2.42-21.5c-4.029 0-7.06 2.693-7.06 8h3.955c0-2.304.906-4.189 3.024-4.189 1.247 0 2.57.828 2.684 2.411.123 1.666-.767 2.511-1.892 3.582-2.924 2.78-2.816 4.049-2.816 7.196h3.943c0-1.452-.157-2.508 1.838-4.659 1.331-1.436 2.986-3.222 3.021-5.943.047-3.963-2.751-6.398-6.697-6.398z"}]]]) 27 | 28 | (defn fullscreen-icon [] 29 | [:svg {:class "fullscreen" :view-box "0 0 18 18" :width "22px" :height "22px"} 30 | [:g [:path {:style {:transform "scale(.75)"} :d "M10 5h-3l5-5 5 5h-3v3h-4v-3zm4 14h3l-5 5-5-5h3v-3h4v3zm-9-5v3l-5-5 5-5v3h3v4h-3zm14-4v-3l5 5-5 5v-3h-3v-4h3z"}]]]) 31 | 32 | (defn minimize-icon [] 33 | [:svg {:class "fullscreen" :view-box "0 0 18 18" :width "22px" :height "22px"} 34 | [:g [:path {:style {:transform "scale(.75)"} :d "M14 3h3l-5 5-5-5h3v-3h4v3zm-4 18h-3l5-5 5 5h-3v3h-4v-3zm-7-11v-3l5 5-5 5v-3h-3v-4h3zm18 4v3l-5-5 5-5v3h3v4h-3z"}]]]) 35 | 36 | (defn location-icon [] 37 | [:svg {:class "location" :view-box "0 0 6 6" :width "16px" :height "16px"} 38 | [:g [:path {:style {:transform "scale(0.25)"} :d "M12 0c-4.198 0-8 3.403-8 7.602 0 4.198 3.469 9.21 8 16.398 4.531-7.188 8-12.2 8-16.398 0-4.199-3.801-7.602-8-7.602zm0 11c-1.657 0-3-1.343-3-3s1.343-3 3-3 3 1.343 3 3-1.343 3-3 3z"}]]]) 39 | 40 | (defn triangle-icon [& [classes]] ; a.k.a. caret 41 | [:svg {:class (str "triangle " classes) :view-box "0 0 6 6" :width "16px" :height "16px"} 42 | [:g [:path {:style {:transform "scale(0.25)"} :d "M23.677 18.52c.914 1.523-.183 3.472-1.967 3.472h-19.414c-1.784 0-2.881-1.949-1.967-3.472l9.709-16.18c.891-1.483 3.041-1.48 3.93 0l9.709 16.18z"}]]]) 43 | 44 | (defn zoom-in-icon [] 45 | [:svg {:class "zoom-in" :view-box "0 0 7 7" :width "22px" :height "22px"} 46 | [:g [:path {:style {:transform "scale(0.25)"} :d "M13 10h-3v3h-2v-3h-3v-2h3v-3h2v3h3v2zm8.172 14l-7.387-7.387c-1.388.874-3.024 1.387-4.785 1.387-4.971 0-9-4.029-9-9s4.029-9 9-9 9 4.029 9 9c0 1.761-.514 3.398-1.387 4.785l7.387 7.387-2.828 2.828zm-12.172-8c3.859 0 7-3.14 7-7s-3.141-7-7-7-7 3.14-7 7 3.141 7 7 7z"}]]]) 47 | 48 | (defn zoom-out-icon [] 49 | [:svg {:class "zoom-out" :view-box "0 0 7 7" :width "22px" :height "22px"} 50 | [:g [:path {:style {:transform "scale(0.25)"} :d "M13 10h-8v-2h8v2zm8.172 14l-7.387-7.387c-1.388.874-3.024 1.387-4.785 1.387-4.971 0-9-4.029-9-9s4.029-9 9-9 9 4.029 9 9c0 1.761-.514 3.398-1.387 4.785l7.387 7.387-2.828 2.828zm-12.172-8c3.859 0 7-3.14 7-7s-3.141-7-7-7-7 3.14-7 7 3.141 7 7 7z"}]]]) 51 | 52 | (defn x-icon [] 53 | [:svg {:class "x" :view-box "0 0 6 6" :width "16px" :height "16px"} 54 | [:g [:path {:style {:transform "scale(0.25)"} :d "M24 20.188l-8.315-8.209 8.2-8.282-3.697-3.697-8.212 8.318-8.31-8.203-3.666 3.666 8.321 8.24-8.206 8.313 3.666 3.666 8.237-8.318 8.285 8.203z"}]]]) 55 | 56 | (defn plus-icon [& [transform]] 57 | [:svg {:class "plus" :view-box "0 0 6 6" :width "16px" :height "16px"} 58 | [:g [:path {:style {:transform (or transform "scale(0.25)")} :d "M24 9h-9v-9h-6v9h-9v6h9v9h6v-9h9z"}]]]) 59 | 60 | (defn cancel-icon [& [transform]] 61 | [:svg {:class "cancel-icon" :view-box "0 0 6 6" :width "16px" :height "16px"} 62 | [:g [:path {:style {:transform (or transform "scale(0.25)")} :d "M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm4.151 17.943l-4.143-4.102-4.117 4.159-1.833-1.833 4.104-4.157-4.162-4.119 1.833-1.833 4.155 4.102 4.106-4.16 1.849 1.849-4.1 4.141 4.157 4.104-1.849 1.849z"}]]]) 63 | 64 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 65 | ;; simple spinner ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 66 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 67 | 68 | (defn simple-loading-animation [] 69 | [:svg.loader 70 | [:path {:fill "#fff" 71 | :d "M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50"}]]) 72 | 73 | (defn loading-screen [] 74 | [:div.center-vh (simple-loading-animation)]) 75 | 76 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 77 | ;; plane/globe animation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 78 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 79 | 80 | (defonce plane-animation-iterations (r/atom 0)) 81 | 82 | ; this needs to get re-called in each function to get its fresh values 83 | ; #logo-animation is the id of the parent wrapper where this animation is placed 84 | (defn get-animation-elem [] (goog.dom/getElement "logo-animation")) 85 | 86 | (defn start-animation [] 87 | (when debug? (println "start-animation")) 88 | (gc/remove (get-animation-elem) "no-animation") 89 | (reset! plane-animation-iterations 0)) 90 | 91 | (defn stop-animation [] 92 | (when debug? (println "stop-animation:" @plane-animation-iterations)) 93 | (swap! plane-animation-iterations inc) 94 | (when (>= @plane-animation-iterations 3) 95 | (gc/add (get-animation-elem) "no-animation"))) 96 | 97 | (defn animated-globe [] 98 | (js/setTimeout #(let [elem (get-animation-elem)] 99 | (.addEventListener elem "mouseover" start-animation) 100 | (.addEventListener elem "webkitAnimationIteration" stop-animation) ;; for Chrome 101 | (.addEventListener elem "animationiteration" stop-animation) ;; for Firefox 102 | (.addEventListener elem "MSAnimationIteration" stop-animation) ;; for IE 103 | (.addEventListener elem "animationiteration" stop-animation) 104 | (when debug? (println "adding listeners to elem"))) 105 | 1000) ; give time to load the animation 106 | (js/setTimeout #(gc/remove (goog.dom/getElement "little-plane") "hidden") 200) 107 | [:div {:class "globe-loader fas fa-globe-americas"} 108 | [:i.fas.fa-plane {:class "hidden" ; this will get removed after the timeout is completed 109 | :id "little-plane"}]]) 110 | -------------------------------------------------------------------------------- /src/smallworld/user_data.cljs: -------------------------------------------------------------------------------- 1 | (ns smallworld.user-data 2 | (:require [reagent.core :as r] 3 | [clojure.pprint :as pp] 4 | [clojure.string :as str] 5 | [smallworld.util :as util] 6 | [smallworld.mapbox :as mapbox] 7 | [smallworld.session :as session] 8 | [smallworld.decorations :as decorations])) 9 | 10 | (def debug? false) 11 | (defonce *friends (r/atom :loading)) 12 | 13 | (defn render-user [k user] 14 | (let [twitter-pic (:profile_image_url_large user) 15 | twitter-name (:name user) 16 | twitter-handle (:screen-name user) 17 | twitter-link (str "http://twitter.com/" twitter-handle) 18 | twitter-href {:href twitter-link :target "_blank" :title "Twitter"} 19 | first-location (first (:locations user)) ; consider pulling from the "Twitter location" location or from the nearest location to the current user, instead of simply pulling the first location in the array 20 | lat (when first-location (:lat (:coords first-location))) 21 | lng (when first-location (:lng (:coords first-location)))] 22 | [:div.friend {:key twitter-handle} 23 | [:a twitter-href 24 | [:div.twitter-pic [:img {:src twitter-pic :key k}]]] 25 | [:div.right-section 26 | [:a.top twitter-href 27 | [:span.name twitter-name] 28 | [:span.handle "@" twitter-handle]] 29 | [:div.bottom 30 | [:a {:href (str "https://www.google.com/maps/search/" lat "%20" lng "?hl=en&source=opensearch") 31 | :title "Google Maps" 32 | :target "_blank"} 33 | [:span.location (:name first-location)]]]]])) 34 | 35 | (defn render-user-bubble [k user] 36 | (let [twitter-pic (:profile_image_url_large user) 37 | twitter-handle (:screen-name user)] 38 | [:div.friend {:key twitter-handle} 39 | [:a ; TODO: on click center map on their face 40 | [:div.twitter-pic [:img {:src twitter-pic :key k}]]]])) 41 | 42 | ; TODO: the logic in this needs some serious cleanup; probably requires refactoring the data model too 43 | (defn get-close-friends [curr-user-location-name friend-location-key max-distance] 44 | (->> @*friends 45 | 46 | ; not all friends will have both LOCATION and DISPLAY NAME LOCATION set, so filter those out 47 | (filter (fn [friend] 48 | (let [friend-locations (:locations friend) 49 | has-location? (-> #(= (:special-status %) friend-location-key) 50 | (filter friend-locations) 51 | first 52 | nil? 53 | not)] 54 | has-location?))) 55 | 56 | (filter (fn [friend] (let [friend-locations (:locations friend) 57 | friend-location (-> #(= (:special-status %) friend-location-key) 58 | (filter friend-locations) 59 | first) 60 | distance-to-curr-user-location (get-in friend-location 61 | [:distances (keyword curr-user-location-name)])] 62 | (when debug? 63 | (println) 64 | (println " curr-user-location-name: " curr-user-location-name) 65 | (println " friend-location: " friend-location) 66 | (println "distance-to-curr-user-location: " distance-to-curr-user-location) 67 | (println " boolean:" (and (not (nil? distance-to-curr-user-location)) 68 | (> max-distance distance-to-curr-user-location)))) 69 | (and (not (nil? distance-to-curr-user-location)) 70 | (> max-distance distance-to-curr-user-location))))) 71 | 72 | (sort-by (fn [friend] (let [friend-locations (:locations friend) 73 | friend-location (-> #(= (:special-status %) friend-location-key) 74 | (filter friend-locations) 75 | first) 76 | distance-to-curr-user-location (get-in friend-location 77 | [:distances (keyword curr-user-location-name)])] 78 | distance-to-curr-user-location))))) 79 | 80 | (def *expanded? (r/atom {})) 81 | 82 | (defn render-friends-list [curr-user-location-i friend-location-key verb-gerund curr-user-location-name] 83 | (assert (or (= friend-location-key "twitter-location") 84 | (= friend-location-key "from-display-name"))) ; TODO: add Scheme to encode this more nicely 85 | (let [verb-gerund-info-text (if (= verb-gerund "visiting") 86 | "when a friend includes a nearby location in their display name, they'll show up on this list" 87 | "when a friend's Twitter location is nearby, they'll show up on this list") 88 | key-pair [curr-user-location-i friend-location-key] 89 | verb-gerund [:span.verb-gerund verb-gerund] 90 | friends-list (if (= :loading @*friends) 91 | [] 92 | (get-close-friends curr-user-location-name friend-location-key 100)) 93 | list-count (count friends-list) 94 | friend-pluralized (if (= list-count 1) "friend is" "friends are") 95 | expanded? (boolean (get @*expanded? key-pair))] 96 | 97 | [util/error-boundary 98 | [:div.friends-list 99 | (if (or (= :loading @*friends) (and @mapbox/*loading (= 0 (count @*friends)))) 100 | [:div.loading (decorations/simple-loading-animation) "fetching your Twitter friends..."] 101 | (if (> list-count 0) 102 | [:<> 103 | [:p.location-info 104 | [:span {:title "expand for details" 105 | :on-click #(swap! *expanded? assoc key-pair (not expanded?))} 106 | (decorations/triangle-icon (clojure.string/join " " ["caret" (if expanded? "down" "right")])) 107 | [:<> 108 | list-count " " 109 | friend-pluralized " " 110 | verb-gerund " " curr-user-location-name]] 111 | [:a {:data-tooltip verb-gerund-info-text 112 | :class (if (< (.-innerWidth js/window) 500) 113 | "tooltip-left" 114 | "tooltip-right")} 115 | (decorations/info-icon)]] 116 | 117 | [:div.friends {:style (when-not expanded? {:visibility "collapse" :overflow "hidden" :height 0 :margin 0})} (map-indexed render-user friends-list)] 118 | [:div.friend-bubbles {:style (when expanded? {:visibility "collapse" :height 0 :margin 0}) 119 | :title "expand for details" 120 | :on-click #(swap! *expanded? assoc key-pair (not expanded?))} 121 | (map-indexed render-user-bubble (take 200 friends-list))]] 122 | 123 | [:div.no-friends-found 124 | (decorations/x-icon) 125 | "0 friends are " verb-gerund " " curr-user-location-name 126 | [:a {:data-tooltip verb-gerund-info-text 127 | :class (if (< (.-innerWidth js/window) 500) 128 | "tooltip-left" 129 | "tooltip-right")} 130 | (decorations/info-icon)]]))]])) 131 | 132 | (defn refetch-friends [] 133 | (util/fetch "/api/v1/friends/refetch-twitter" 134 | (fn [result] 135 | (reset! *friends result) 136 | (mapbox/add-friends-to-map @*friends @session/*store)))) 137 | 138 | (defn recompute-friends [& [callback]] 139 | (util/fetch "/api/v1/friends/recompute-locations" 140 | (fn [result] 141 | (when (or debug? (= (.. js/window -location -hash) "#debug")) 142 | (println "/api/v1/friends/recompute-locations: " (count result))) 143 | (when callback (callback)) 144 | (reset! *friends result) 145 | (mapbox/add-friends-to-map @*friends @session/*store)))) -------------------------------------------------------------------------------- /src/smallworld/clj_postgresql/types.clj: -------------------------------------------------------------------------------- 1 | ;; adapted from https://github.com/remodoy/clj-postgresql/blob/38eec16386e43504addcae4601fa36f595c81f1a/src/clj_postgresql/types.clj 2 | 3 | ; TODO: there is probalby some unused code in here that can be cleared out 4 | 5 | (ns smallworld.clj-postgresql.types 6 | "Participate in clojure.java.jdbc's ISQLValue and IResultSetReadColumn protocols 7 | to allow using PostGIS geometry types without the PGgeometry wrapper, support the 8 | PGjson type and allow coercing clojure structures into PostGIS types." 9 | (:require [clojure.java.jdbc :as jdbc] 10 | [clojure.xml :as xml] 11 | [cheshire.core :as json]) 12 | (:import [org.postgresql.util PGobject] 13 | [java.sql PreparedStatement ParameterMetaData])) 14 | 15 | ;; 16 | ;; Helpers 17 | ;; 18 | 19 | (defn pmd 20 | "Convert ParameterMetaData to a map." 21 | [^java.sql.ParameterMetaData md i] 22 | {:parameter-class (.getParameterClassName md i) 23 | :parameter-mode (.getParameterMode md i) 24 | :parameter-type (.getParameterType md i) 25 | :parameter-type-name (.getParameterTypeName md i) 26 | :precision (.getPrecision md i) 27 | :scale (.getScale md i) 28 | :nullable? (.isNullable md i) 29 | :signed? (.isSigned md i)}) 30 | 31 | (defn rsmd 32 | "Convert ResultSetMetaData to a map." 33 | [^java.sql.ResultSetMetaData md i] 34 | {:catalog-name (.getCatalogName md i) 35 | :column-class-name (.getColumnClassName md i) 36 | :column-display-size (.getColumnDisplaySize md i) 37 | :column-label (.getColumnLabel md i) 38 | :column-type (.getColumnType md i) 39 | :column-type-name (.getColumnTypeName md i) 40 | :precision (.getPrecision md i) 41 | :scale (.getScale md i) 42 | :schema-name (.getSchemaName md i) 43 | :table-name (.getTableName md i) 44 | :auto-increment? (.isAutoIncrement md i) 45 | :case-sensitive? (.isCaseSensitive md i) 46 | :currency? (.isCurrency md i) 47 | :definitely-writable? (.isDefinitelyWritable md i) 48 | :nullable? (.isNullable md i) 49 | :read-only? (.isReadOnly md i) 50 | :searchable? (.isSearchable md i) 51 | :signed? (.isSigned md i) 52 | :writable? (.isWritable md i)}) 53 | 54 | ;;;; 55 | ;; 56 | ;; Data type conversion for SQL query parameters 57 | ;; 58 | ;;;; 59 | 60 | 61 | ;; 62 | ;; Extend clojure.java.jdbc's protocol for getting SQL values of things to support PostGIS objects. 63 | ;; 64 | ;; (extend-protocol jdbc/ISQLValue 65 | ;; org.postgis.Geometry 66 | ;; (sql-value [v] 67 | ;; (PGgeometryLW. v))) 68 | 69 | ;; 70 | ;; Extend clojure.java.jdbc's protocol for converting query parameters to SQL values. 71 | ;; We try to determine which SQL type is correct for which clojure structure. 72 | ;; 1. See query parameter meta data. JDBC might already know what PostgreSQL wants. 73 | ;; 2. Look into parameter's clojure metadata for type hints 74 | ;; 75 | 76 | ;; multimethod selector for conversion funcs 77 | (defn parameter-dispatch-fn 78 | [_ type-name] 79 | (keyword type-name)) 80 | 81 | ;; 82 | ;; Convert Clojure maps to SQL parameter values 83 | ;; 84 | 85 | (defmulti map->parameter parameter-dispatch-fn) 86 | 87 | (defn- to-pg-json [data json-type] 88 | (doto (PGobject.) 89 | (.setType (name json-type)) 90 | (.setValue (json/generate-string data)))) 91 | 92 | (defmethod map->parameter :json 93 | [m _] 94 | (to-pg-json m :json)) 95 | 96 | (defmethod map->parameter :jsonb 97 | [m _] 98 | (to-pg-json m :jsonb)) 99 | (extend-protocol jdbc/ISQLParameter 100 | clojure.lang.IPersistentMap 101 | (set-parameter [m ^PreparedStatement s ^long i] 102 | (let [meta (.getParameterMetaData s)] 103 | (if-let [type-name (keyword (.getParameterTypeName meta i))] 104 | (.setObject s i (map->parameter m type-name)) 105 | (.setObject s i m))))) 106 | 107 | ;; 108 | ;; Convert clojure vectors to SQL parameter values 109 | ;; 110 | 111 | (defmulti vec->parameter parameter-dispatch-fn) 112 | 113 | (defmethod vec->parameter :json 114 | [v _] 115 | (to-pg-json v :json)) 116 | 117 | (defmethod vec->parameter :jsonb 118 | [v _] 119 | (to-pg-json v :jsonb)) 120 | 121 | (defmethod vec->parameter :inet 122 | [v _] 123 | (if (= (count v) 4) 124 | (doto (PGobject.) (.setType "inet") (.setValue (clojure.string/join "." v))) 125 | v)) 126 | 127 | (defmethod vec->parameter :default 128 | [v _] 129 | v) 130 | 131 | (extend-protocol jdbc/ISQLParameter 132 | clojure.lang.IPersistentVector 133 | (set-parameter [v ^PreparedStatement s ^long i] 134 | (let [conn (.getConnection s) 135 | meta (.getParameterMetaData s) 136 | type-name (.getParameterTypeName meta i)] 137 | (if-let [elem-type (when type-name (second (re-find #"^_(.*)" type-name)))] 138 | (.setObject s i (.createArrayOf conn elem-type (to-array v))) 139 | (.setObject s i (vec->parameter v type-name)))))) 140 | 141 | ;; 142 | ;; Convert all sequables to SQL parameter values by handling them like vectors. 143 | ;; 144 | 145 | (extend-protocol jdbc/ISQLParameter 146 | clojure.lang.Seqable 147 | (set-parameter [seqable ^PreparedStatement s ^long i] 148 | (jdbc/set-parameter (vec (seq seqable)) s i))) 149 | 150 | ;; 151 | ;; Convert numbers to SQL parameter values. 152 | ;; Conversion is done for target types like timestamp 153 | ;; for which it makes sense to accept numeric values. 154 | ;; 155 | 156 | (defmulti num->parameter parameter-dispatch-fn) 157 | 158 | (defmethod num->parameter :timestamptz 159 | [v _] 160 | (java.sql.Timestamp. v)) 161 | 162 | (defmethod num->parameter :timestamp 163 | [v _] 164 | (java.sql.Timestamp. v)) 165 | 166 | (defmethod num->parameter :default 167 | [v _] 168 | v) 169 | 170 | (extend-protocol clojure.java.jdbc/ISQLParameter 171 | java.lang.Number 172 | (set-parameter [num ^java.sql.PreparedStatement s ^long i] 173 | (let [meta (.getParameterMetaData s) 174 | type-name (.getParameterTypeName meta i)] 175 | (.setObject s i (num->parameter num type-name))))) 176 | 177 | ;; Inet addresses 178 | (extend-protocol clojure.java.jdbc/ISQLParameter 179 | java.net.InetAddress 180 | (set-parameter [^java.net.InetAddress inet-addr ^java.sql.PreparedStatement s ^long i] 181 | (.setObject s i (doto (PGobject.) 182 | (.setType "inet") 183 | (.setValue (.getHostAddress inet-addr)))))) 184 | 185 | ;;;; 186 | ;; 187 | ;; Data type conversions for query result set values. 188 | ;; 189 | ;;;; 190 | 191 | 192 | ;; 193 | ;; PGobject parsing magic 194 | ;; 195 | 196 | (defn read-pg-vector 197 | "oidvector, int2vector, etc. are space separated lists" 198 | [s] 199 | (when (seq s) (clojure.string/split s #"\s+"))) 200 | 201 | (defn read-pg-array 202 | "Arrays are of form {1,2,3}" 203 | [s] 204 | (when (seq s) (when-let [[_ content] (re-matches #"^\{(.+)\}$" s)] (if-not (empty? content) (clojure.string/split content #"\s*,\s*") [])))) 205 | 206 | (defmulti read-pgobject 207 | "Convert returned PGobject to Clojure value." 208 | #(keyword (when % (.getType ^org.postgresql.util.PGobject %)))) 209 | 210 | (defmethod read-pgobject :oidvector 211 | [^org.postgresql.util.PGobject x] 212 | (when-let [val (.getValue x)] 213 | (mapv read-string (read-pg-vector val)))) 214 | 215 | (defmethod read-pgobject :int2vector 216 | [^org.postgresql.util.PGobject x] 217 | (when-let [val (.getValue x)] 218 | (mapv read-string (read-pg-vector val)))) 219 | 220 | (defmethod read-pgobject :anyarray 221 | [^org.postgresql.util.PGobject x] 222 | (when-let [val (.getValue x)] 223 | (vec (read-pg-array val)))) 224 | 225 | (defmethod read-pgobject :json 226 | [^org.postgresql.util.PGobject x] 227 | (when-let [val (.getValue x)] 228 | (json/parse-string val))) 229 | 230 | (defmethod read-pgobject :jsonb 231 | [^org.postgresql.util.PGobject x] 232 | (when-let [val (.getValue x)] 233 | (json/parse-string val))) 234 | 235 | (defmethod read-pgobject :default 236 | [^org.postgresql.util.PGobject x] 237 | (.getValue x)) 238 | 239 | ;; 240 | ;; Extend clojure.java.jdbc's protocol for interpreting ResultSet column values. 241 | ;; 242 | (extend-protocol jdbc/IResultSetReadColumn 243 | 244 | ;; Parse SQLXML to a Clojure map representing the XML content 245 | java.sql.SQLXML 246 | (result-set-read-column [val _ _] 247 | (xml/parse (.getBinaryStream val))) 248 | 249 | ;; Covert java.sql.Array to Clojure vector 250 | java.sql.Array 251 | (result-set-read-column [val _ _] 252 | (vec (.getArray val))) 253 | 254 | ;; PGobjects have their own multimethod 255 | org.postgresql.util.PGobject 256 | (result-set-read-column [val _ _] 257 | (read-pgobject val))) -------------------------------------------------------------------------------- /src/smallworld/db.clj: -------------------------------------------------------------------------------- 1 | (ns smallworld.db 2 | (:require [clojure.java.io :as io] 3 | [clojure.java.jdbc :as sql] ; https://clojure.github.io/java.jdbc 4 | [clojure.pprint :as pp] 5 | [clojure.string :as str] 6 | [clojure.walk :as walk] 7 | [jdbc.pool.c3p0 :as pool] 8 | [smallworld.clj-postgresql.types] ; this enables the :json type 9 | [smallworld.util :as util])) 10 | 11 | (def debug? false) 12 | (def db-uri (java.net.URI. (util/get-env-var "DATABASE_URL"))) 13 | (def user-and-password 14 | (if (nil? (.getUserInfo db-uri)) 15 | nil (clojure.string/split (.getUserInfo db-uri) #":"))) 16 | (def pool 17 | (delay 18 | (pool/make-datasource-spec 19 | {:classname "org.postgresql.Driver" 20 | :subprotocol "postgresql" 21 | :user (get user-and-password 0) 22 | :password (get user-and-password 1) 23 | :subname (if (= -1 (.getPort db-uri)) 24 | (format "//%s%s" (.getHost db-uri) (.getPath db-uri)) 25 | (format "//%s:%s%s" (.getHost db-uri) (.getPort db-uri) (.getPath db-uri)))}))) 26 | 27 | ; Small World table names 28 | (def twitter-profiles-table :twitter_profiles) ; store all data from Twitter sign up 29 | (def settings-table :settings) ; store Small World-specific settings 30 | (def friends-table :friends) ; memoized storage: friends of the user (request_key) 31 | (def coordinates-table :coordinates) ; memoized storage: map of city/country names to coordinates 32 | (def access_tokens-table :access_tokens) ; memoized storage: Twitter access tokens 33 | (def impersonation-table :impersonation) ; stores screen_name of the user who the admin is impersonating (for debug only) 34 | 35 | (def twitter-profiles-schema (slurp (io/resource "sql/schema-twitter-profiles.sql"))) 36 | (def settings-schema (slurp (io/resource "sql/schema-settings.sql"))) 37 | (def friends-schema (slurp (io/resource "sql/schema-friends.sql"))) 38 | (def coordinates-schema (slurp (io/resource "sql/schema-coordinates.sql"))) 39 | (def access-tokens-schema (slurp (io/resource "sql/schema-access-tokens.sql"))) 40 | (def impersonation-schema (slurp (io/resource "sql/schema-impersonation.sql"))) 41 | (def users-schema (slurp (io/resource "sql/schema-users.sql"))) 42 | 43 | (defn escape-str [str] ; TODO: replace this this the ? syntax, which escapes for you 44 | (str/replace str "'" "''")) 45 | 46 | (defn where [column-name value] 47 | (str " where " (name column-name) " = '" (escape-str value) "'")) 48 | 49 | (defn table-exists? [table-name] 50 | (->> table-name 51 | name 52 | escape-str 53 | (#(sql/query @pool (str "SELECT table_name FROM information_schema.tables where table_name = '" % "'"))) 54 | count 55 | (not= 0))) 56 | 57 | (defn create-table [table-name schema] 58 | (if (table-exists? table-name) 59 | (println "table" table-name "already exists") 60 | (do 61 | (println "creating table" table-name) 62 | (if (string? schema) 63 | (sql/db-do-commands @pool (clojure.string/split schema #"--- split here ---")) 64 | (sql/db-do-commands @pool (sql/create-table-ddl (name table-name) schema)))))) 65 | 66 | (defn recreate-table [table-name schema] ; leave this commented out by default, since it's destructive 67 | (sql/db-do-commands @pool (str " drop table if exists " (name table-name))) 68 | (create-table table-name schema) 69 | (when debug? 70 | (println "done dropping table named " table-name " (if it existed)") 71 | (println "done creating table named " table-name))) 72 | 73 | (defn select-all [table] 74 | (sql/query @pool (str "select * from " (name table)))) 75 | 76 | (defn select-first [table] 77 | (first (sql/query @pool (str "select * from " (name table) " limit 1")))) 78 | 79 | (defn show-all [table-name] 80 | (println) 81 | (let [results (if (= table-name friends-table) 82 | (sql/query @pool (str "select request_key from " (name friends-table))) 83 | (sql/query @pool (str "select * from " (name table-name))))] 84 | (pp/pprint results) 85 | (when (= table-name friends-table) 86 | (println "not printing {:data {:friends}} because it's too long")) 87 | (println "\ncount: " (count results))) 88 | (println)) 89 | 90 | (defn get-coordinate [location-name] ; case-insensitive 91 | (if (nil? location-name) 92 | [] 93 | (walk/keywordize-keys 94 | (sql/query @pool (str "select * from " (name coordinates-table) 95 | (str " where lower(request_key) = lower('" (escape-str location-name) "')")))))) 96 | 97 | (defn select-by-col [table-name col-name col-value] 98 | (when debug? 99 | (println "(select-by-col" table-name col-name col-value ")")) 100 | (if (nil? col-value) 101 | [] 102 | (walk/keywordize-keys 103 | (sql/query @pool (str "select * from " (name table-name) 104 | (where col-name col-value)))))) 105 | 106 | (defn insert! [table-name data] 107 | (when debug? 108 | (println "inserting the following data into" table-name) 109 | (pp/pprint data)) 110 | (sql/insert! @pool table-name data)) 111 | 112 | ; TODO: this was meant to simplify the code, but it's best to just replace it 113 | ; everywhere with sql/update! probably 114 | (defn update! [table-name col-name col-value new-json] 115 | (sql/update! @pool table-name new-json [(str (name col-name) " = ?") 116 | col-value])) 117 | 118 | ; TODO: turn this into a single query to speed it up 119 | (defn memoized-insert-or-update! [table-name request_key data] 120 | (let [sql-results (select-by-col table-name :request_key request_key) 121 | exists? (not= 0 (count sql-results))] 122 | (when debug? 123 | (println "result:" sql-results) 124 | (println "exists? " exists?) 125 | (pp/pprint (select-by-col table-name :request_key request_key))) 126 | (if-not exists? 127 | (insert! table-name {:request_key request_key :data data}) 128 | (update! table-name :request_key request_key {:data data})))) 129 | 130 | (defn insert-or-update! [table-name col-name data] 131 | (let [col-name (keyword col-name) 132 | col-value (get data col-name) 133 | sql-results (select-by-col table-name col-name col-value) 134 | exists? (not= 0 (count sql-results)) 135 | new-data (dissoc (merge (first sql-results) data) 136 | :id :updated_at) 137 | new-data (if (not= table-name settings-table) 138 | new-data 139 | (assoc new-data ; TODO: this only applies to settings table, not any others! yuck 140 | :locations (or (:locations data) 141 | (vec (:locations (first sql-results))))))] 142 | (when debug? 143 | (println "--- running fn: insert-or-update! ---------") 144 | (println "col-name: " col-name) 145 | (println "col-value: " col-value) 146 | (println "sql-results:" sql-results) 147 | (println "exists? " exists?) 148 | (println "data (arg): " data) 149 | (println "new data (merged): ") 150 | (pp/pprint new-data) 151 | (println "-------------------------------------------")) 152 | (if-not exists? 153 | (insert! table-name data) 154 | (update! table-name col-name col-value new-data)))) 155 | 156 | (defn update-twitter-last-fetched! [screen-name] 157 | (sql/db-do-commands @pool (str "update settings " 158 | "set twitter_last_fetched = now() " 159 | "where screen_name = '" screen-name "';"))) 160 | 161 | (comment 162 | (do 163 | (println "--------------------------------") 164 | (println) 165 | (pp/pprint (first (select-by-col settings-table :screen_name "devon_dos"))) 166 | (println) 167 | (pp/pprint (:email_address (first (select-by-col settings-table :screen_name "devon_dos")))) 168 | (println) 169 | (println "--------------------------------")) 170 | 171 | (recreate-table settings-table settings-schema) 172 | (insert! settings-table {:screen_name "aaa" :main_location_corrected "bbb"}) 173 | (update! settings-table :screen_name "aaa" {:welcome_flow_complete true}) 174 | (update! settings-table :screen_name "aaa" {:screen_name "foo"}) 175 | (update! settings-table :screen_name "foo" {:screen_name "aaa"}) 176 | (insert-or-update! settings-table :screen_name 177 | {:screen_name "devonzuegel" :main_location_corrected "bbb"}) 178 | (insert-or-update! settings-table :screen_name 179 | {:screen_name "devon_dos" :welcome_flow_complete false}) 180 | (insert-or-update! settings-table :screen_name 181 | {:screen_name "devon_dos" :email_address "1@gmail.com"}) 182 | (select-by-col settings-table :screen_name "devonzuegel") 183 | (show-all settings-table) 184 | 185 | (show-all twitter-profiles-table) 186 | 187 | (recreate-table friends-table friends-schema) 188 | (select-by-col friends-table :request_key "devonzuegel") 189 | (get-in (vec (select-by-col friends-table :request_key "devon_dos")) [0 :data :friends]) 190 | (select-by-col friends-table :request_key "meadowmaus") 191 | (show-all friends-table) 192 | 193 | (sql/delete! @pool friends-table ["request_key = ?" "devonzuegel"]) 194 | (sql/delete! @pool access_tokens-table ["request_key = ?" "devonzuegel"]) 195 | (show-all access_tokens-table) 196 | 197 | (select-by-col access_tokens-table :request_key "devonzuegel") 198 | (select-by-col access_tokens-table :request_key "meadowmaus") 199 | 200 | (recreate-table :coordinates friends-schema) 201 | (show-all :coordinates) 202 | (pp/pprint (select-by-col :coordinates :request_key "Miami Beach")) 203 | (update! :coordinates :request_key "Miami Beach" {:data {:lat 25.792236328125 :lng -80.13484954833984}}) 204 | (select-by-col :coordinates :request_key "spain")) 205 | -------------------------------------------------------------------------------- /src/smallworld/user_data.clj: -------------------------------------------------------------------------------- 1 | (ns smallworld.user-data 2 | (:require [clojure.string :as str] 3 | [clojure.test :refer [deftest is]] 4 | [smallworld.coordinates :as coordinates]) 5 | (:import (java.util.regex Pattern))) 6 | 7 | (def debug? false) 8 | 9 | (defn ^StringBuilder fast-replace [^StringBuilder sb ^Pattern pattern ^String replacement] 10 | (let [matcher (.matcher pattern sb)] 11 | (loop [start 0] 12 | (when (.find matcher start) 13 | (.replace sb (.start matcher) (.end matcher) replacement) 14 | (recur (+ (.start matcher) (.length replacement)))))) 15 | sb) 16 | 17 | (defn normal-img-to-full-size [friend] 18 | (let [original-url (:profile-image-url-https friend)] 19 | (if (nil? original-url) 20 | nil 21 | (str (fast-replace (StringBuilder. original-url) #"_normal" ""))))) 22 | 23 | (defn includes? [string substr] 24 | (str/includes? (str/lower-case string) substr)) 25 | 26 | (defn split-last [string splitter] 27 | (or (last (str/split string splitter)) "")) 28 | 29 | (defn remove-substr [^StringBuilder sb substr] 30 | (fast-replace sb substr "")) 31 | 32 | (defn normalize-location [^String name] ; case insensitive – used for coordinate lookup only, not for display 33 | (let [_s (-> (str/lower-case (or name "")) 34 | (StringBuilder.) 35 | (remove-substr #"(?i)they/them") 36 | (remove-substr #"(?i)she/her") 37 | (remove-substr #"(?i)he/him") 38 | (remove-substr #"(?i) soon") 39 | (remove-substr #"(?i) mostly") 40 | (remove-substr #"(?i) still") 41 | (remove-substr #"(?i)Planet Earth") 42 | (remove-substr #"(?i)Earth") 43 | (str))] 44 | (cond 45 | (= _s "sf") "san francisco, california" 46 | (= _s "home") "" 47 | (includes? _s "at home") "" 48 | (includes? _s "subscribe") "" 49 | (includes? _s ".com") "" 50 | (includes? _s ".net") "" 51 | (includes? _s ".org") "" 52 | (includes? _s ".eth") "" 53 | (includes? _s "solana") "" 54 | (includes? _s "blue/green sphere") "" 55 | (includes? _s "pale blue dot") "" 56 | (includes? _s "zoom") "" 57 | (includes? _s "san francisco") "san francisco, california" 58 | (includes? _s "sf, ") "san francisco, california" 59 | (includes? _s "nyc") "new york city" 60 | (includes? _s "new york") "new york city" 61 | :else (let [_s (StringBuilder. _s) 62 | _s (remove-substr _s #" \([^)]*\)") ; remove anything in parentheses (e.g. "sf (still!)" → "sf") 63 | _s (if (= "bay area") 64 | _s 65 | (str/replace _s #"(?i) area$" "")) 66 | _s (-> (str _s) 67 | (split-last #"\/") 68 | (split-last #" and ") 69 | (split-last #"\|") 70 | (split-last #"→") 71 | (split-last #"·") 72 | (split-last #"•") 73 | (split-last #"✈️") 74 | (split-last #"🔜")) 75 | ^String _s (split-last _s #"➡️") 76 | _s (StringBuilder. _s) 77 | _s (remove-substr _s #",$") ; remove trailing comma 78 | _s (fast-replace _s #"[^a-zA-Z]" " ") ; remove any remaining non-letter/non-comma strings with spaces (e.g. emoji) 79 | _s (str _s)] 80 | (cond 81 | (> (count (str/split _s #" ")) 3) "" ; if there are more than 3 words, it's probably a sentence and not a place name 82 | (= _s "new york") "new york city" 83 | (= _s "california") "san francisco" 84 | (= _s "bay area") "san francisco bay" 85 | (= _s "british columbia") "vancouver, canada" ; approx. center of population in British Columbia 86 | (= _s "canada") "whiteshell provincial park" ; approx. center of population in Canada 87 | (= _s "québec") "québec, québec, canada" ; they probably meant Quebec city, not Québec province 88 | :else (str/trim _s)))))) 89 | 90 | (deftest test-normalize-location 91 | (is (= (normalize-location "sf, foobar") "san francisco, california")) 92 | (is (= (normalize-location "New York") "new york city")) 93 | (is (= (normalize-location "Zoom") "")) 94 | (is (= (normalize-location "Seattle area") "seattle")) 95 | (is (= (normalize-location "CHQ → London") "london")) 96 | (is (= (normalize-location "Planet Earth") ""))) 97 | 98 | (defn title-case [s] 99 | (->> (str/split (str s) #"\b") 100 | (map str/capitalize) 101 | str/join)) 102 | 103 | (defn location-from-name [name] 104 | (let [_s (str/replace name #" in " "|") 105 | _s (str/replace _s #" \| " "|") 106 | _s (str/replace _s #"(?i) soon!?$" "") 107 | _s (str/replace _s #" visiting " "|") 108 | _s (str/replace _s #" at " "|") 109 | _s (str/split _s #"\|")] 110 | (if (< 1 (count _s)) ; if there's only 1 element, assume they didn't put a location in their name 111 | (title-case (normalize-location (last _s))) 112 | ""))) 113 | 114 | (deftest test-location-from-name 115 | (is (= (location-from-name "Devon ☀️ in Buenos Aires") "Buenos Aires")) 116 | (is (= (location-from-name "Devon visiting SF") "San Francisco, California")) 117 | (is (= (location-from-name "Devon visiting London soon") "London")) 118 | (is (= (location-from-name "Devon visiting London soon!") "London")) 119 | (is (= (location-from-name "Miami Beach Police") "")) 120 | (is (= (location-from-name "Fairchild Garden") "")) 121 | (is (= (location-from-name "Devon") ""))) 122 | 123 | (defn distances-map [is-current-user? current-user friend-coords] 124 | (when (not is-current-user?) ; distances aren't relevant if the friend whose data we're abridging is the current user 125 | (zipmap 126 | (map :name (:locations current-user)) 127 | (map #(coordinates/distance-btwn (:coords %) friend-coords) 128 | (:locations current-user))))) 129 | 130 | ;; "main" refers to the location set in the Twitter :location field 131 | ;; "name-location" refers to the location described in their Twitter :name (which may be nil) 132 | (defn abridged [friend current-user] 133 | (let [is-current-user? (= (:screen-name current-user) (:screen-name friend)) 134 | ; locations as strings 135 | friend-main-location (normalize-location (:location friend)) 136 | friend-name-location (location-from-name (:name friend)) 137 | ; locations as coordinates 138 | friend-main-coords (coordinates/memoized (or friend-main-location "")) 139 | friend-name-coords (coordinates/memoized (or friend-name-location ""))] 140 | 141 | (when debug? 142 | (println "---------------------------------------------------") 143 | (println) 144 | (println " location:" (:location friend)) 145 | (println " friend-main-location:" friend-main-location) 146 | (println " name:" (:name friend)) 147 | (println " friend-name-location:" friend-name-location) 148 | (println) 149 | (println " friend-main-coords:" friend-main-coords) 150 | (println " friend-main-coords:" friend-name-coords) 151 | (println) 152 | (println " is-current-user?:" is-current-user?) 153 | (println " current-user:" current-user) 154 | (println " friend name:" (:name friend)) 155 | (println " friend email:" (:email friend)) 156 | (println "-----------------------------------------------")) 157 | 158 | {:name (:name friend) 159 | :screen-name (:screen-name friend) 160 | :profile_image_url_large (normal-img-to-full-size friend) 161 | ; note – email will only be available if the user has given us permission 162 | ; (i.e. if they are also the current-user) AND if they have set their email 163 | ; on Twitter, which is not required, so sometimes it'll be the empty string 164 | :email (:email friend) 165 | :locations [(when (not (str/blank? friend-main-location)) 166 | {:special-status "twitter-location" ; formerly called "main location" 167 | :name (:location friend) 168 | :coords friend-main-coords 169 | :distances (distances-map is-current-user? current-user friend-main-coords)}) 170 | 171 | (when (not (str/blank? friend-name-location)) 172 | {:special-status "from-display-name" ; formerly called "name location" 173 | :name friend-name-location 174 | :coords friend-name-coords 175 | :distances (distances-map is-current-user? current-user friend-name-coords)})]})) 176 | 177 | (defn deg-to-rad [deg] 178 | (* (/ deg 180) Math/PI)) 179 | 180 | (defn coord-distance-miles [[lat1 lng1] [lat2 lng2]] 181 | (let [earth-radius-miles 3959 182 | lat1 (deg-to-rad lat1) 183 | lng1 (deg-to-rad lng1) 184 | lat2 (deg-to-rad lat2) 185 | lng2 (deg-to-rad lng2) 186 | lat-diff (- lat2 lat1) 187 | lng-diff (- lng2 lng1)] 188 | ; haversine formula 189 | (let [a (+ (* (Math/sin (/ lat-diff 2)) 190 | (Math/sin (/ lat-diff 2))) 191 | (* (Math/cos lat1) 192 | (Math/cos lat2) 193 | (* (Math/sin (/ lng-diff 2)) 194 | (Math/sin (/ lng-diff 2))))) 195 | c (* 2 (Math/atan2 (Math/sqrt a) (Math/sqrt (- 1 a))))] 196 | (* earth-radius-miles c)))) 197 | 198 | (deftest test-coord-distance-miles 199 | (let [miami [25.792236328125 -80.13484954833984] 200 | sf [37.773972 -122.431297] 201 | nyc [40.730610 -73.935242] 202 | sydney [-33.865143 151.209900] 203 | bangalore [12.971599 77.594563]] 204 | (is (= (coord-distance-miles miami miami) 0.0)) 205 | (is (= (coord-distance-miles sydney sydney) 0.0)) 206 | (is (= (coord-distance-miles miami sydney) 9341.340521295822)) 207 | (is (= (coord-distance-miles miami bangalore) 9368.559527598409)) 208 | (is (= (coord-distance-miles miami nyc) 1091.808718763545)) 209 | (is (= (coord-distance-miles miami sf) 2593.8337289018637)))) -------------------------------------------------------------------------------- /resources/public/terms-and-conditions.html: -------------------------------------------------------------------------------- 1 | 6 |
7 |

Terms and Conditions

8 |

Last updated: February 17, 2022

9 |

Please read these terms and conditions carefully before using Our Service.

10 |

Interpretation and Definitions

11 |

Interpretation

12 |

13 | The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear 14 | in singular or in plural. 15 |

16 |

Definitions

17 |

For the purposes of these Terms and Conditions:

18 | 63 |

Acknowledgment

64 |

65 | These are the Terms and Conditions governing the use of this Service and the agreement that operates between You and the Company. These Terms and Conditions set out the rights and obligations of 66 | all users regarding the use of the Service. 67 |

68 |

69 | Your access to and use of the Service is conditioned on Your acceptance of and compliance with these Terms and Conditions. These Terms and Conditions apply to all visitors, users and others who 70 | access or use the Service. 71 |

72 |

By accessing or using the Service You agree to be bound by these Terms and Conditions. If You disagree with any part of these Terms and Conditions then You may not access the Service.

73 |

You represent that you are over the age of 18. The Company does not permit those under 18 to use the Service.

74 |

75 | Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on 76 | the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our 77 | Privacy Policy carefully before using Our Service. 78 |

79 |

Links to Other Websites

80 |

Our Service may contain links to third-party web sites or services that are not owned or controlled by the Company.

81 |

82 | The Company has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that the 83 | Company shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such content, goods 84 | or services available on or through any such web sites or services. 85 |

86 |

We strongly advise You to read the terms and conditions and privacy policies of any third-party web sites or services that You visit.

87 |

Termination

88 |

We may terminate or suspend Your access immediately, without prior notice or liability, for any reason whatsoever, including without limitation if You breach these Terms and Conditions.

89 |

Upon termination, Your right to use the Service will cease immediately.

90 |

Limitation of Liability

91 |

92 | Notwithstanding any damages that You might incur, the entire liability of the Company and any of its suppliers under any provision of this Terms and Your exclusive remedy for all of the foregoing 93 | shall be limited to the amount actually paid by You through the Service or 100 USD if You haven't purchased anything through the Service. 94 |

95 |

96 | To the maximum extent permitted by applicable law, in no event shall the Company or its suppliers be liable for any special, incidental, indirect, or consequential damages whatsoever (including, 97 | but not limited to, damages for loss of profits, loss of data or other information, for business interruption, for personal injury, loss of privacy arising out of or in any way related to the use 98 | of or inability to use the Service, third-party software and/or third-party hardware used with the Service, or otherwise in connection with any provision of this Terms), even if the Company or any 99 | supplier has been advised of the possibility of such damages and even if the remedy fails of its essential purpose. 100 |

101 |

102 | Some states do not allow the exclusion of implied warranties or limitation of liability for incidental or consequential damages, which means that some of the above limitations may not apply. In 103 | these states, each party's liability will be limited to the greatest extent permitted by law. 104 |

105 |

"AS IS" and "AS AVAILABLE" Disclaimer

106 |

107 | The Service is provided to You "AS IS" and "AS AVAILABLE" and with all faults and defects without warranty of any kind. To the maximum extent permitted under applicable law, 108 | the Company, on its own behalf and on behalf of its Affiliates and its and their respective licensors and service providers, expressly disclaims all warranties, whether express, implied, statutory 109 | or otherwise, with respect to the Service, including all implied warranties of merchantability, fitness for a particular purpose, title and non-infringement, and warranties that may arise out of 110 | course of dealing, course of performance, usage or trade practice. Without limitation to the foregoing, the Company provides no warranty or undertaking, and makes no representation of any kind 111 | that the Service will meet Your requirements, achieve any intended results, be compatible or work with any other software, applications, systems or services, operate without interruption, meet any 112 | performance or reliability standards or be error free or that any errors or defects can or will be corrected. 113 |

114 |

115 | Without limiting the foregoing, neither the Company nor any of the company's provider makes any representation or warranty of any kind, express or implied: (i) as to the operation or availability 116 | of the Service, or the information, content, and materials or products included thereon; (ii) that the Service will be uninterrupted or error-free; (iii) as to the accuracy, reliability, or 117 | currency of any information or content provided through the Service; or (iv) that the Service, its servers, the content, or e-mails sent from or on behalf of the Company are free of viruses, 118 | scripts, trojan horses, worms, malware, timebombs or other harmful components. 119 |

120 |

121 | Some jurisdictions do not allow the exclusion of certain types of warranties or limitations on applicable statutory rights of a consumer, so some or all of the above exclusions and limitations may 122 | not apply to You. But in such a case the exclusions and limitations set forth in this section shall be applied to the greatest extent enforceable under applicable law. 123 |

124 |

Governing Law

125 |

126 | The laws of the Country, excluding its conflicts of law rules, shall govern this Terms and Your use of the Service. Your use of the Application may also be subject to other local, state, national, 127 | or international laws. 128 |

129 |

Disputes Resolution

130 |

If You have any concern or dispute about the Service, You agree to first try to resolve the dispute informally by contacting the Company.

131 |

For European Union (EU) Users

132 |

If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which you are resident in.

133 |

United States Legal Compliance

134 |

135 | You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a 136 | "terrorist supporting" country, and (ii) You are not listed on any United States government list of prohibited or restricted parties. 137 |

138 |

Severability and Waiver

139 |

Severability

140 |

141 | If any provision of these Terms is held to be unenforceable or invalid, such provision will be changed and interpreted to accomplish the objectives of such provision to the greatest extent 142 | possible under applicable law and the remaining provisions will continue in full force and effect. 143 |

144 |

Waiver

145 |

146 | Except as provided herein, the failure to exercise a right or to require performance of an obligation under these Terms shall not effect a party's ability to exercise such right or require such 147 | performance at any time thereafter nor shall the waiver of a breach constitute a waiver of any subsequent breach. 148 |

149 |

Translation Interpretation

150 |

These Terms and Conditions may have been translated if We have made them available to You on our Service. You agree that the original English text shall prevail in the case of a dispute.

151 |

Changes to These Terms and Conditions

152 |

153 | We reserve the right, at Our sole discretion, to modify or replace these Terms at any time. If a revision is material We will make reasonable efforts to provide at least 30 days' notice prior to 154 | any new terms taking effect. What constitutes a material change will be determined at Our sole discretion. 155 |

156 |

157 | By continuing to access or use Our Service after those revisions become effective, You agree to be bound by the revised terms. If You do not agree to the new terms, in whole or in part, please 158 | stop using the website and the Service. 159 |

160 |

Contact Us

161 |

If you have any questions about these Terms and Conditions, You can contact us:

162 | 165 |
166 | -------------------------------------------------------------------------------- /src/smallworld/screens/home.cljs: -------------------------------------------------------------------------------- 1 | (ns smallworld.screens.home 2 | (:require [clojure.string :as str] 3 | [reagent.core :as r] 4 | [reitit.frontend.easy :as rfe] 5 | [smallworld.decorations :as decorations] 6 | [smallworld.mapbox :as mapbox] 7 | [smallworld.session :as session] 8 | [smallworld.user-data :as user-data] 9 | [smallworld.util :as util] 10 | [smallworld.screens.settings :as settings])) 11 | 12 | (def *debug? (r/atom false)) 13 | (defonce *minimaps (r/atom {})) 14 | 15 | (defn minimap [minimap-id location-name coords] 16 | (r/create-class {:component-did-mount 17 | (fn [] ; this should be called just once when the component is mounted 18 | (swap! *minimaps assoc minimap-id 19 | (new js/mapboxgl.Map 20 | #js{:container minimap-id 21 | :key (get-in mapbox/config [mapbox/style :access-token]) 22 | :style (get-in mapbox/config [mapbox/style :style]) 23 | :center (clj->js [(:lng coords) (:lat coords)]) 24 | :interactive false ; makes the map not zoomable or draggable 25 | :attributionControl false ; removes the Mapbox copyright symbol 26 | :zoom 1 27 | :maxZoom 8 28 | :minZoom 0})) 29 | ; zoom out if they haven't provided a location 30 | (when (clojure.string/blank? location-name) 31 | (.setZoom (get @*minimaps minimap-id) 0))) 32 | :reagent-render (fn [] [:div {:id minimap-id}])})) 33 | 34 | (defn debugger-btn [] 35 | (when (= (.. js/window -location -hash) "#debug") 36 | [:p {:style {:text-align "center"}} 37 | [:a {:on-click #(reset! *debug? (not @*debug?)) :href "#" :style {:border-bottom "2px solid #ffffff33"}} 38 | "toggle debug – currently " (if @*debug? "on 🟢" "off 🔴")]])) 39 | 40 | (defn debugger-info [] 41 | (when @*debug? 42 | [:<> [:br] [:br] [:hr] 43 | [:div.refresh-friends {:style {:margin-top "64px" :text-align "center"}} 44 | [:a.btn {:href "#" 45 | :on-click #(util/fetch "/api/v1/friends" 46 | (fn [result] 47 | (reset! user-data/*friends result) 48 | (println settings/*settings) 49 | (js/setTimeout (mapbox/add-friends-to-map @user-data/*friends @settings/*settings) 2000)) 50 | :retry? true)} 51 | "pull friends again (without refreshing from Twitter!)"]] 52 | 53 | [:br] 54 | [:div.refresh-friends {:style {:margin-top "64px" :text-align "center"}} 55 | [:div {:style {:margin-bottom "12px" :font-size "0.9em"}} 56 | "does the data for your friends look out of date?"] 57 | [:a.btn {:href "#" :on-click user-data/refetch-friends} 58 | "refresh friends"] 59 | [:div {:style {:margin-top "12px" :font-size "0.8em" :opacity "0.6" :font-family "Inria Serif, serif" :font-style "italic"}} 60 | "note: this takes several seconds to run"]] 61 | 62 | [:br] 63 | [:b "current-user:"] 64 | [:pre (util/preify @session/*store)] [:br] 65 | [:b "settings:"] 66 | [:pre (util/preify @settings/*settings)] [:br] 67 | [:b "@user-data/*friends: (showing max of 5 only)"] 68 | (if (= @user-data/*friends :loading) 69 | [:pre "@user-data/*friends is still :loading"] 70 | [:pre "count:" (count @user-data/*friends) "\n\n" (util/preify (take 5 @user-data/*friends))])])) 71 | 72 | ; TODO: lots of low-hanging fruit to improve performance 73 | (defn fetch-coordinates! [minimap-id location-name-input i] 74 | (if (str/blank? location-name-input) ; don't fetch from the API if the input is blank 75 | (.flyTo (get @*minimaps minimap-id) #js{:essential true ; this animation is essential with respect to prefers-reduced-motion 76 | :zoom 0}) 77 | (util/fetch-post "/api/v1/coordinates" {:location-name location-name-input} 78 | (fn [result] 79 | (.flyTo (get @*minimaps minimap-id) 80 | #js{:essential true ; this animation is essential with respect to prefers-reduced-motion 81 | :zoom 1 82 | :center #js[(:lng result) (:lat result)]}) 83 | (when (and (:lat result) (:lng result)) 84 | (swap! settings/*settings assoc-in [:locations i :coords] result) 85 | (user-data/recompute-friends 86 | #(swap! settings/*settings assoc-in [:locations i :loading] false)) 87 | (util/fetch-post "/api/v1/settings/update" ; persist the changes to the server 88 | {:locations (:locations (assoc-in @settings/*settings [:locations i :loading] false))})))))) 89 | 90 | (def fetch-coordinates-debounced! (util/debounce fetch-coordinates! 400)) 91 | 92 | (defn from-twitter? [location-data] 93 | (or (= (:special-status location-data) "twitter-location") 94 | (= (:special-status location-data) "from-display-name"))) 95 | 96 | (defn -screen [] 97 | [:<> 98 | (util/nav) 99 | (let [curr-user-locations (remove nil? (:locations @settings/*settings)) 100 | update [:a {:href "https://twitter.com/settings/profile" :target "_blank"} "update"] 101 | track-new-location-btn [:div#track-new-location-field 102 | {:on-click (fn [] 103 | (let [updated-locations (vec (concat curr-user-locations ; using concat instead of conj so it adds to the end 104 | [{:special-status "added-manually" 105 | :name "" ; the value starts out blank 106 | :coords nil}]))] 107 | (swap! settings/*settings assoc :locations updated-locations) 108 | 109 | (js/setTimeout #(do 110 | (-> (last (util/query-dom ".friends-list input")) 111 | .focus) 112 | (-> (last (util/query-dom ".category")) 113 | (.scrollIntoView #js{:behavior "smooth" :block "center" :inline "center"}))) 114 | 50)))} 115 | (decorations/plus-icon "scale(0.15)") "follow a new location"]] 116 | [:div.home-page 117 | [:div.announcement-banner 118 | [:p.header "JULY 25, 2023:"] 119 | [:p "Sadly, Small World's Twitter API access has been " [:a {:href "https://twitter.com/devonzuegel/status/1681137630019481601" :target "_blank"} "shut off"] ", so your data won't refresh anymore. "] 120 | [:p "The good news is that I've found an alternative: Fedica, which has " [:a {:href "https://fedica.com/twitter/map-people-you-follow?_by=smallworld&fp_sid=kiwi" :target "_blank"} "a similar map feature as Small World"] ". Enjoy!"] 121 | [:p "Small World's stale data might still be useful, since people don't move that often. And if I find a way to get API access in the future, I'll let you know."] 122 | [:p "— Devon"]] 123 | (let [top-location (first (remove nil? (:locations @settings/*settings)))] 124 | [util/error-boundary 125 | [mapbox/mapbox 126 | {:lng-lat (:coords top-location) 127 | :location (:name top-location) 128 | :user-img (:profile_image_url_large @session/*store) 129 | :user-name (:name @session/*store) 130 | :screen-name (:screen-name @session/*store)}]]) 131 | 132 | (when (not= 0 (count curr-user-locations)) 133 | track-new-location-btn) 134 | 135 | (doall (map-indexed 136 | (fn [i location-data] 137 | (let [minimap-id (str "minimap-location-" i)] 138 | [:div.category {:key i} 139 | [:div.friends-list.header 140 | 141 | [:div.left-side.mapbox-container 142 | [minimap minimap-id (:name location-data) (:coords location-data)] 143 | (when-not (str/blank? (:name location-data)) [:div.center-point])] 144 | 145 | [:div.right-side 146 | [:div.based-on (condp = (:special-status location-data) 147 | "twitter-location" "based on your Twitter location, you live in:" 148 | "from-display-name" "based on your Twitter name, you're visiting:" 149 | nil)] 150 | [:input {:type "text" 151 | :value (:name location-data) 152 | :autoComplete "off" 153 | :auto-complete "off" 154 | :style {:cursor (when-not (from-twitter? location-data) "pointer")} 155 | :readOnly (from-twitter? location-data) ; don't allow them to edit the Twitter locations 156 | :placeholder "type a location to follow" 157 | :on-change #(let [input-elem (.-target %) 158 | new-value (.-value input-elem)] 159 | (swap! settings/*settings assoc-in [:locations i :loading] true) 160 | (fetch-coordinates-debounced! minimap-id new-value i) 161 | (swap! settings/*settings assoc-in [:locations i :name] new-value) 162 | (util/fetch-post "/api/v1/settings/update" {:locations (:locations @settings/*settings)}))}] 163 | #_(when (from-twitter? location-data) ; no longer needed because they aren't editable 164 | [:div.small-info-text "this won't update your Twitter profile :)"])] 165 | 166 | [:div.delete-location-btn {:title "delete this location" 167 | :on-click #(when (js/confirm "are you sure that you want to delete this location? don't worry, you can add it back later any time") 168 | (let [updated-locations (util/rm-from-list curr-user-locations i)] 169 | (println "settings/*settings :locations (BEFORE)") 170 | (println (:locations @settings/*settings)) 171 | (swap! settings/*settings assoc :locations updated-locations) 172 | (println "settings/*settings :locations (AFTER)") 173 | (println (:locations @settings/*settings)) 174 | (util/fetch-post "/api/v1/settings/update" {:locations updated-locations})))} 175 | (decorations/cancel-icon)]] 176 | 177 | (if (or (get-in @settings/*settings [:locations i :loading]) 178 | (= [] @user-data/*friends) 179 | (= :loading @user-data/*friends)) 180 | [:div.friends-list [:div.loading (decorations/simple-loading-animation) "fetching your Twitter friends..."]] 181 | (when-not (nil? (:coords location-data)) 182 | [:<> ; TODO: refactor this so that data is passed in as a param, rather than depending on side effects 183 | (user-data/render-friends-list i "twitter-location" "based near" (:name location-data)) 184 | (user-data/render-friends-list i "from-display-name" "visiting" (:name location-data))]))])) 185 | curr-user-locations)) 186 | 187 | [:div.no-locations-info 188 | [:p "3 ways to start following a location:"] 189 | [:ul 190 | [:li update " your Twitter profile location"] 191 | [:li update " your Twitter display name (e.g. \"Devon in NYC\")"] ; TODO: make this an `i` info hover 192 | [:li "add a location manually:"]] 193 | track-new-location-btn] 194 | 195 | [:br] [:br] 196 | (debugger-btn) 197 | 198 | (settings/info-footer (:screen-name @session/*store) 199 | user-data/recompute-friends) ; TODO: replace with doseq, which is for side effects 200 | (debugger-info)])]) 201 | 202 | (defn screen [] 203 | (r/create-class 204 | {:component-did-mount 205 | (fn [] 206 | (settings/refresh-friends) ; refresh immediately 207 | (doall (for [i (range 2 5)] ; then refresh it again, with exponential backoff 208 | (js/setTimeout settings/refresh-friends (* (util/exponent 2 i) 1000))))) 209 | :reagent-render (fn [] [-screen])})) 210 | -------------------------------------------------------------------------------- /resources/public/css/meetcute.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | border: 0; 4 | outline: 0; 5 | font-size: 100%; 6 | vertical-align: baseline; 7 | background: transparent; 8 | } 9 | 10 | html { 11 | color: #2b2017; 12 | // color: #013917; 13 | background: #fcfcfc; 14 | // for a hacky dark mode when coding at night: 15 | // background: #2b2017; 16 | // color: #ccc; 17 | font-family: 'Roboto Mono', monospace; 18 | } 19 | 20 | pre { 21 | text-wrap: wrap; 22 | } 23 | 24 | a { 25 | color: rgb(46, 103, 63); 26 | font-weight: 700; 27 | text-decoration: none; 28 | // background: rgb(42, 98, 59, 0.1); 29 | // padding: 2px 4px; 30 | border-bottom: 2px solid transparent; 31 | border-bottom: 2px solid rgb(42, 98, 59, 0.2); 32 | transition: border-bottom 0.1s ease-in-out; 33 | } 34 | 35 | a:hover { 36 | // text-decoration: underline; 37 | border-bottom: 2px solid rgb(42, 98, 59, 0.3); 38 | transition: border-bottom 0.1s ease-in-out; 39 | } 40 | 41 | button, 42 | a, 43 | textarea { 44 | font-family: 'Roboto Mono', monospace; 45 | } 46 | 47 | .btn { 48 | color: rgb(188, 181, 175, 1); 49 | border: 3px solid rgb(188, 181, 175, 0); 50 | transition: border 0.2s ease-in-out; 51 | padding: 12px 14px; 52 | border-radius: 8px; 53 | cursor: pointer; 54 | margin: 6px; 55 | display: inline-block; 56 | text-decoration: none; 57 | font-weight: bold; 58 | } 59 | 60 | .btn:hover { 61 | color: rgb(108, 98, 90); 62 | transition: all 0.1s ease-in-out; 63 | border: 3px solid rgb(188, 181, 175, 0); 64 | } 65 | 66 | .btn.primary { 67 | border: 3px solid rgb(188, 181, 175, 0.4); 68 | } 69 | 70 | .btn.primary:hover { 71 | border: 3px solid rgb(188, 181, 175, 0.7); 72 | } 73 | 74 | .btn.primary.green { 75 | background: rgb(42, 98, 59, 0.08); 76 | border: 3px solid rgb(42, 98, 59, 0.8); 77 | color: rgb(42, 98, 59, 0.8); 78 | transition: all 0.1s ease-in-out; 79 | } 80 | 81 | .btn.primary.green:hover { 82 | background: rgb(42, 98, 59, 0.1); 83 | border: 3px solid rgb(42, 98, 59, 0.9); 84 | color: rgb(42, 98, 59, 1); 85 | transition: all 0.1s ease-in-out; 86 | } 87 | 88 | body { 89 | margin: 0; 90 | } 91 | 92 | html, 93 | body { 94 | height: 100%; 95 | } 96 | 97 | input { 98 | color: #220b01; 99 | transition: color 200ms ease-in-out; 100 | font-family: 'Roboto Mono', monospace; 101 | accent-color: #054921e0; 102 | } 103 | 104 | input:focus { 105 | color: #000000; 106 | transition: color 200ms ease-in-out; 107 | } 108 | 109 | input[type='radio'] { 110 | width: 20px; 111 | height: 20px; 112 | } 113 | 114 | input[type='checkbox'] { 115 | width: 16px; 116 | height: 16px; 117 | } 118 | 119 | .input-container input { 120 | border: none; 121 | box-sizing: border-box; 122 | outline: 0; 123 | padding: 0.75rem; 124 | position: relative !important; 125 | width: 100%; 126 | } 127 | 128 | input[type='date'] { 129 | cursor: pointer; 130 | } 131 | 132 | input[type='date']::-webkit-calendar-picker-indicator { 133 | width: 100%; 134 | height: 22px; 135 | cursor: pointer; 136 | // opacity: 0.1; 137 | // height: 20px; 138 | // position: absolute; 139 | // fill parent: 140 | // top: 0; 141 | // left: 0; 142 | // right: 0; 143 | // bottom: 0; 144 | // visibility: hidden; 145 | } 146 | 147 | .input-date-overlay { 148 | display: block; 149 | margin-bottom: -38px; 150 | margin-left: 36px; 151 | top: 0px; 152 | padding: 6px 0px; 153 | font-size: 16px; 154 | display: block; 155 | margin-bottom: -28px; 156 | margin-left: 36px; 157 | position: relative; 158 | pointer-events: none; 159 | } 160 | 161 | /* keep form inputs from zooming on mobile: https://www.warrenchandler.com/2019/04/02/stop-iphones-from-zooming-in-on-form-fields */ 162 | body { 163 | line-height: 1.3em; 164 | font-size: 14px; 165 | } 166 | 167 | input, 168 | select { 169 | font-size: 16px; 170 | } 171 | 172 | ul { 173 | padding-inline-start: 20px; 174 | } 175 | 176 | li { 177 | padding-left: 4px; 178 | } 179 | 180 | .loading-container { 181 | position: fixed; 182 | top: 40%; 183 | left: 50%; 184 | transform: translate(-50%, -50%); 185 | } 186 | 187 | .loading-container .spinner { 188 | width: 120px; 189 | height: 120px; 190 | margin: auto; 191 | animation: spin 1.7s ease-in-out infinite; 192 | background-image: url('/orange-favicon.png'); 193 | background-size: contain; 194 | } 195 | 196 | @keyframes spin { 197 | 0% { 198 | transform: rotate(0deg); 199 | } 200 | 201 | 100% { 202 | transform: rotate(360deg); 203 | } 204 | } 205 | 206 | .bio-row .title { 207 | width: fit-content; 208 | padding: 16px 12px 0 12px; 209 | opacity: 0.75; 210 | font-weight: bold; 211 | text-transform: uppercase; 212 | font-style: italic; 213 | color: #a19890; 214 | font-size: 0.8em; 215 | } 216 | 217 | .bio-row .required-note { 218 | color: rgb(255 106 0); 219 | font-size: 1.3em; 220 | text-transform: lowercase; 221 | margin: 0 4px; 222 | } 223 | 224 | .bio-row textarea { 225 | background: white; 226 | border: 3px solid rgba(188, 181, 175, 0.3); 227 | border-radius: 8px; 228 | resize: vertical; 229 | padding: 16px 20px; 230 | margin-right: 12px; 231 | min-height: 140px; 232 | color: #333; 233 | font-size: 0.9em; 234 | width: 98%; 235 | transition: all 0.2s ease-in-out; 236 | } 237 | 238 | .required textarea:empty:not(:focus), 239 | .required .editable-input.required-but-empty, 240 | .required .editable-input[value='']:not([type='date']):not(:focus) { 241 | background-color: rgb(255, 248, 243); 242 | border: 3px solid rgb(255, 181, 128); 243 | transition: all 0.2s ease-in-out; 244 | } 245 | 246 | .required .editable-input.required-but-empty input { 247 | background-color: rgb(255, 248, 243); 248 | } 249 | 250 | .bio-row-locations .bio-row { 251 | width: 100%; 252 | } 253 | 254 | .bio-row-value { 255 | max-width: 78vw; 256 | display: flex; 257 | } 258 | 259 | .bio-row-value input:focus:not([type='date']) { 260 | border: 3px solid rgba(188, 181, 175, 0.6) !important; 261 | transition: all 0.2s ease-in-out; 262 | } 263 | 264 | @media screen and (min-width: 600px) { 265 | .bio-row-value { 266 | min-width: 370px; 267 | } 268 | } 269 | 270 | .required-but-empty, 271 | .required-not-empty { 272 | padding: 6px 8px 8px 8px; 273 | border: 3px solid transparent; 274 | } 275 | 276 | .required-but-empty { 277 | background-color: rgb(255, 248, 243); 278 | border: 3px solid rgb(255, 181, 128); 279 | border-radius: 8px; 280 | } 281 | 282 | .editable-input { 283 | background: white; 284 | border: 3px solid rgba(188, 181, 175, 0.3); 285 | border-radius: 8px; 286 | padding: 6px 8px; 287 | margin-right: 4px; 288 | width: 95%; 289 | max-width: 380px; 290 | } 291 | 292 | .editable-input .editable-input { 293 | margin: 0; 294 | padding: 0; 295 | width: 100%; 296 | border: none; 297 | } 298 | 299 | input#display-phone, 300 | .iti--separate-dial-code .iti__selected-flag { 301 | background: rgba(35, 81, 49, 0.08) !important; 302 | } 303 | 304 | #display-phone::placeholder { 305 | color: rgba(25, 56, 34, 0.4); 306 | } 307 | 308 | input#email, 309 | .iti--separate-dial-code .iti__selected-flag { 310 | background: rgba(35, 81, 49, 0.08) !important; 311 | } 312 | 313 | #email::placeholder { 314 | color: rgba(25, 56, 34, 0.4); 315 | } 316 | 317 | .iti__country-list { 318 | max-width: 330px; 319 | overflow-x: hidden; 320 | font-size: 0.8em; 321 | } 322 | 323 | .oranges-wallpaper { 324 | background-color: #ffe2c0; 325 | background-image: url('../oranges-tile-repeat.jpg'); 326 | background-size: 420px; 327 | background-image: url('../oranges-tile-repeat-2.jpg'); 328 | background-size: min(700px, 90vw); 329 | background-position: 50% 0; 330 | background-position: 50% 0; 331 | height: calc(100vh - 15vh); 332 | 333 | // make children centered vertically on page, but no more than 120px from the top 334 | display: flex; 335 | flex-direction: column; 336 | justify-content: center; 337 | align-items: center; 338 | padding-top: 15vh; 339 | } 340 | 341 | .oranges-wallpaper form { 342 | padding-top: 48px; 343 | } 344 | 345 | .signin-form-background { 346 | box-shadow: 0 0 2px 0 rgba(255, 255, 255, 1); 347 | border: 4px solid rgb(42, 98, 59, 1); 348 | color: rgb(7, 69, 25); 349 | background: rgb(252 252 252 / 100%); 350 | max-width: 290px; 351 | margin-left: auto; 352 | margin-right: auto; 353 | width: 90%; 354 | padding: 24px; 355 | text-align: center; 356 | border-radius: 12px; 357 | } 358 | 359 | ////////////////////////////////////////////////// 360 | // Expandable component 361 | 362 | // this currently doesn't work, not sure why... maybe because on plane? 363 | details { 364 | font-size: 1.4em; 365 | color: red; 366 | cursor: pointer; 367 | } 368 | 369 | details span.title { 370 | margin-left: 8px; 371 | font-size: 1.5em; 372 | } 373 | 374 | details>summary { 375 | // width: 100%; 376 | width: fit-content; 377 | // border-top: 1px solid #e0e0e0; 378 | padding: 32px 6px 6px 6px; 379 | cursor: pointer; 380 | font-weight: bold; 381 | } 382 | 383 | details>summary:first-of-type { 384 | border-top: none; 385 | margin-top: 8px; 386 | } 387 | 388 | details[open]>summary::marker, 389 | details>summary::marker, 390 | details summary::-webkit-details-marker, 391 | details summary::marker { 392 | font-size: 1.4em; 393 | display: inline-block; 394 | } 395 | 396 | ////////////////////////////////////////////////// 397 | /// table 398 | 399 | table { 400 | border-collapse: collapse; 401 | border-spacing: 0; 402 | width: 100%; 403 | border: 1px solid #ddd; 404 | } 405 | 406 | th, 407 | td { 408 | text-align: left; 409 | padding: 4px; 410 | } 411 | 412 | .invisible { 413 | opacity: 0; 414 | } 415 | 416 | .location-field:focus-within, 417 | .email-options:focus-within, 418 | .email-options:focus 419 | 420 | /* the radio btns field is different */ 421 | .email-address:focus, 422 | .email-address:focus-within { 423 | border: 1px solid #ffffff55; 424 | } 425 | 426 | .you-signed-in-as .friend { 427 | width: fit-content; 428 | } 429 | 430 | .you-signed-in-as { 431 | display: inline-flex; 432 | } 433 | 434 | .field .edit-icon { 435 | fill: white; 436 | height: 13px; 437 | margin-left: -36px; 438 | margin-top: 15px; 439 | position: absolute; 440 | opacity: 0.5; 441 | } 442 | 443 | .field { 444 | font-size: 0.95em; 445 | } 446 | 447 | .mapbox-container { 448 | margin: auto; 449 | width: 110px; 450 | height: 110px; 451 | margin: 0; 452 | margin-top: 10px; 453 | background: #ffffff11; 454 | overflow: hidden; 455 | background: #9dc7d9; 456 | border-radius: 100px; 457 | } 458 | 459 | .mapbox-container .mapboxgl-canvas-container, 460 | .mapbox-container .mapboxgl-canvas-container canvas { 461 | border-radius: 100px; 462 | } 463 | 464 | .mapboxgl-canvas { 465 | position: relative !important; 466 | } 467 | 468 | .mapbox-container .mapboxgl-map { 469 | height: 100% !important; 470 | } 471 | 472 | /* pulsating background */ 473 | @keyframes pulsingGreenBkgd { 474 | 0% { 475 | background-color: #1d7a42; 476 | } 477 | 478 | 50% { 479 | background-color: #013917; 480 | } 481 | 482 | 100% { 483 | background-color: #1d7a42; 484 | } 485 | } 486 | 487 | @keyframes pulsingSize { 488 | 0% { 489 | transform: scale(1); 490 | // font-size: 1em; 491 | // transform: translateX(0) translateY(0); 492 | } 493 | 494 | 50% { 495 | transform: scale(1.2); 496 | // font-size: 1.2em; 497 | // transform: translateX(-10px) translateY(-10px); 498 | } 499 | 500 | 100% { 501 | transform: scale(1); 502 | // font-size: 1em; 503 | // transform: translateX(0) translateY(0); 504 | } 505 | } 506 | 507 | .mapbox-container .center-point, 508 | .mapbox-container .center-point { 509 | width: 20px; 510 | height: 20px; 511 | font-size: 1.8em; 512 | background: radial-gradient(circle, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%); 513 | // border: 1px solid white; 514 | // background-color: #e56800; 515 | border-radius: 100%; 516 | position: relative; 517 | left: calc(50%); 518 | top: calc(-50%); 519 | transform: translateX(-50%) translateY(-50%); 520 | // animation-name: pulsingSize; 521 | animation-duration: 3s; 522 | animation-iteration-count: infinite; 523 | } 524 | 525 | .location-fields { 526 | display: grid; 527 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 528 | gap: 16px; 529 | margin: 12px 0; 530 | width: 100%; 531 | } 532 | 533 | .location-field, 534 | .add-location-btn { 535 | background: #f6f6f6; 536 | border: 3px solid rgba(188, 181, 175, 0.15) !important; 537 | border-radius: 8px; 538 | margin: 0; 539 | display: flex; 540 | flex-direction: column; 541 | align-items: center; 542 | padding: 8px; 543 | border-radius: 8px; 544 | flex: 1; 545 | min-width: 200px; 546 | /* Adjust minimum width as needed */ 547 | text-align: center; 548 | border: 1px solid #000; 549 | padding: 10px; 550 | } 551 | 552 | .add-location-btn.empty { 553 | border: 3px dashed rgb(255, 181, 128) !important; 554 | background-color: rgb(255, 248, 243); 555 | } 556 | 557 | .location-field .delete-location-btn { 558 | align-self: end; 559 | margin: 4px; 560 | fill: #bbb; 561 | cursor: pointer; 562 | } 563 | 564 | .location-field .location-field-top { 565 | margin: -12px 0 0 0; 566 | } 567 | 568 | .location-field .location-field-bottom { 569 | margin: 16px 12px 0 12px; 570 | } 571 | 572 | .location-field .location-input { 573 | background: white; 574 | margin: 4px 0 8px 0; 575 | padding: 4px 8px; 576 | width: calc(100% - 24px); 577 | border: 3px solid rgba(188, 181, 175, 0.3) !important; 578 | transition: all 0.2s ease-in-out; 579 | border-radius: 8px; 580 | } 581 | 582 | .location-field .location-input[value='']:not(:focus) { 583 | background-color: rgb(255, 248, 243); 584 | border: 3px solid rgb(255, 181, 128) !important; 585 | transition: all 0.2s ease-in-out; 586 | } 587 | 588 | .location-field .location-input:focus { 589 | border: 3px solid rgba(188, 181, 175, 0.5) !important; 590 | transition: all 0.2s ease-in-out; 591 | border-radius: 8px; 592 | } 593 | 594 | .location-field .location-type-radio { 595 | width: 100%; 596 | text-align: left; 597 | } 598 | 599 | .location-field .location-type-radio input { 600 | margin: 6px; 601 | cursor: pointer; 602 | // display: none; 603 | } 604 | 605 | .location-field .location-type-radio label { 606 | margin: 3px; 607 | padding: 4px 8px; 608 | border-radius: 3px; 609 | line-height: 2.2em; 610 | cursor: pointer; 611 | color: #013917c0; 612 | transition: color 0.1s ease-in-out; 613 | } 614 | 615 | .location-field .location-type-radio label:hover, 616 | .location-field .location-type-radio input:hover+label { 617 | color: #013917e0; 618 | transition: color 0.1s ease-in-out; 619 | } 620 | 621 | .add-location-btn { 622 | padding: 24px 10px; 623 | min-height: 264px; 624 | color: #013917c0; 625 | background: white; 626 | border: 3px dashed rgba(188, 181, 175, 0.4) !important; 627 | border-radius: 8px; 628 | cursor: pointer; 629 | transition: all 0.2s ease-in-out; 630 | font-weight: bolder; 631 | display: inline-flex; 632 | align-items: center; 633 | justify-content: center; 634 | } 635 | 636 | .add-location-btn:hover { 637 | color: #013917; 638 | border: 3px dashed rgba(188, 181, 175, 0.8) !important; 639 | transition: all 0.2s ease-in-out; 640 | } 641 | 642 | .ready-for-review { 643 | position: fixed; 644 | background: white; 645 | border-top: 2px solid rgb(229, 225, 220); 646 | background: rgb(235, 232, 229); 647 | padding: 16px 16px 24px 16px; 648 | box-shadow: 0 0 8px 0 rgba(255, 255, 255, 1); 649 | left: 0; 650 | right: 0; 651 | bottom: 0; 652 | display: flex; 653 | flex-direction: column-reverse; 654 | align-items: center; 655 | justify-content: center; 656 | flex-wrap: wrap-reverse; 657 | gap: 12px; 658 | } 659 | 660 | .ready-for-review button { 661 | color: white; 662 | background: #013917e0; 663 | border-radius: 8px; 664 | padding: 12px 16px; 665 | font-weight: bold; 666 | cursor: pointer; 667 | transition: all 0.2s ease-in-out; 668 | width: fit-content; 669 | min-width: 200px; 670 | } 671 | 672 | .ready-for-review button:hover { 673 | background: #013917; 674 | transition: all 0.2s ease-in-out; 675 | } 676 | 677 | .ready-for-review button.disabled { 678 | background: rgba(160, 160, 160, 0.3); 679 | color: rgba(160, 160, 160, 0.9); 680 | cursor: not-allowed; 681 | } 682 | 683 | .ready-for-review .errors-list { 684 | // width: 336px; 685 | text-wrap: balance; 686 | line-height: 1.5em; 687 | font-size: 0.9em; 688 | text-align: center; 689 | } 690 | 691 | @media (min-width: 570px) { 692 | .ready-for-review .errors-list { 693 | text-align: left; 694 | } 695 | } 696 | 697 | .ready-for-review .errors-list ul { 698 | margin-top: 6px; 699 | } 700 | 701 | .welcome-message { 702 | line-height: 1.8em; 703 | // text-wrap: balance; 704 | display: flex; 705 | align-items: center; 706 | justify-content: space-around; 707 | gap: 24px; 708 | background: #f6f6f6; 709 | background: rgb(235, 232, 229, 0.6); 710 | padding: 24px 48px 24px 32px; 711 | border-radius: 6px; 712 | } 713 | 714 | /**** image upload form ****************************************************************************/ 715 | 716 | .img-upload input[type='file'] { 717 | display: block; 718 | border: 2px dashed #ccc; 719 | text-align: center; 720 | transition: border-color 0.3s ease; 721 | cursor: pointer !important; 722 | } 723 | 724 | .img-upload input[type='file'] { 725 | // border: 3px solid rgba(188, 181, 175, 0.3); 726 | background: rgba(188, 181, 175, 0.2); 727 | width: 80%; 728 | padding: 48px; 729 | margin: 16px 0 8px 0; 730 | border-radius: 8px; 731 | } 732 | 733 | .img-upload.empty input[type='file'] { 734 | border: 3px dashed rgb(255, 181, 128) !important; 735 | background-color: rgb(255, 248, 243); 736 | } 737 | 738 | .img-upload p.error { 739 | color: rgb(255, 106, 0); 740 | margin-bottom: 8px; 741 | } 742 | 743 | // TODO: hmm this isn't working... 744 | #file-upload-button { 745 | margin-right: 8px; 746 | border-radius: 8px; 747 | font-family: 'Roboto Mono', monospace; 748 | border: 3px solid green; 749 | } 750 | 751 | .bio-row .picture { 752 | display: inline-block !important; 753 | } 754 | 755 | .bio-row .picture img { 756 | // height: 200px; 757 | // width: 100px; 758 | // background: pink; 759 | max-height: 200px; 760 | max-width: 100%; 761 | margin: 8px 8px 0 0; 762 | border-radius: 8px; 763 | background: #ccc; 764 | border: 1px solid #ccc; 765 | border-radius: 8px; 766 | } 767 | 768 | .bio-row .picture .cancel-btn { 769 | margin-left: -22px; 770 | margin-top: 4px; 771 | float: right; 772 | cursor: pointer; 773 | } 774 | 775 | .bio-row .picture svg.cancel-icon { 776 | transform: scale(1.1); 777 | border-radius: 8px; 778 | transition: all 100ms ease-in-out; 779 | fill: #718371; 780 | background: white; 781 | border: 1px solid white; 782 | margin-left: -20px; 783 | box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.3); 784 | } 785 | 786 | .bio-row .picture svg.cancel-icon:hover { 787 | fill: #3b5f46; 788 | transition: all 100ms ease-in-out; 789 | transform: scale(1.18); 790 | } 791 | 792 | // .img-upload input[type='submit'] { 793 | // display: block; 794 | // width: 100%; 795 | // padding: 10px; 796 | // background-color: #4caf50; 797 | // color: white; 798 | // border: none; 799 | // border-radius: 8px; 800 | // cursor: pointer; 801 | // font-size: 16px; 802 | // } 803 | 804 | // .img-upload input[type='submit']:hover { 805 | // background-color: #45a049; 806 | // } --------------------------------------------------------------------------------