diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 11fea59..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,47 +0,0 @@
-# This file excludes paths from the Docker build context.
-#
-# By default, Docker's build context includes all files (and folders) in the
-# current directory. Even if a file isn't copied into the container it is still sent to
-# the Docker daemon.
-#
-# There are multiple reasons to exclude files from the build context:
-#
-# 1. Prevent nested folders from being copied into the container (ex: exclude
-# /assets/node_modules when copying /assets)
-# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
-# 3. Avoid sending files containing sensitive information
-#
-# More information on using .dockerignore is available here:
-# https://docs.docker.com/engine/reference/builder/#dockerignore-file
-
-.dockerignore
-
-# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
-#
-# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
-# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
-.git
-!.git/HEAD
-!.git/refs
-
-# Common development/test artifacts
-/cover/
-/doc/
-/test/
-/tmp/
-.elixir_ls
-
-# Mix artifacts
-/_build/
-/deps/
-*.ez
-
-# Generated on crash by the VM
-erl_crash.dump
-
-# Static artifacts - These should be fetched and built inside the Docker image
-/assets/node_modules/
-/priv/static/assets/
-/priv/static/cache_manifest.json
-
-.prv/
\ No newline at end of file
diff --git a/.env b/.env
new file mode 100644
index 0000000..af0f9b3
--- /dev/null
+++ b/.env
@@ -0,0 +1,11 @@
+
+# Giphy API Configuration
+GIPHY_API_KEY=iMV61T4eUDWLTvEon2kgGAqkK9LTYmOY
+
+# OAuth Configuration (existing)
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+TWITTER_CONSUMER_KEY=
+TWITTER_CONSUMER_SECRET=
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9dbe2cb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,11 @@
+
+# Giphy API Configuration
+GIPHY_API_KEY=your_giphy_api_key_here
+
+# OAuth Configuration (existing)
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+TWITTER_CONSUMER_KEY=
+TWITTER_CONSUMER_SECRET=
diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml
deleted file mode 100644
index e0035b8..0000000
--- a/.github/workflows/fly.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: Fly Deploy
-on:
- push:
- branches:
- - develop
-env:
- FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
-jobs:
- deploy:
- name: Deploy app
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
- - uses: superfly/flyctl-actions/setup-flyctl@master
- - run: flyctl deploy --remote-only
diff --git a/.gitignore b/.gitignore
index 0e1b576..6ab1844 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,9 @@ erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
+# Temporary files, for example, from tests.
+/tmp/
+
# Ignore package tarball (built via "mix hex.build").
malarkey-*.tar
@@ -32,5 +35,3 @@ malarkey-*.tar
npm-debug.log
/assets/node_modules/
-
-.prv/
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index b58b603..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
diff --git a/.idea/malarkey.iml b/.idea/malarkey.iml
deleted file mode 100644
index 0c8867d..0000000
--- a/.idea/malarkey.iml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index b85a4b8..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 9b7ed45..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index a3c907d..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "workbench.colorCustomizations": {
- "activityBar.activeBackground": "#e76105",
- "activityBar.background": "#e76105",
- "activityBar.foreground": "#e7e7e7",
- "activityBar.inactiveForeground": "#e7e7e799",
- "activityBarBadge.background": "#025e27",
- "activityBarBadge.foreground": "#e7e7e7",
- "commandCenter.border": "#e7e7e799",
- "sash.hoverBorder": "#e76105",
- "statusBar.background": "#b54c04",
- "statusBar.foreground": "#e7e7e7",
- "statusBarItem.hoverBackground": "#e76105",
- "statusBarItem.remoteBackground": "#b54c04",
- "statusBarItem.remoteForeground": "#e7e7e7",
- "titleBar.activeBackground": "#b54c04",
- "titleBar.activeForeground": "#e7e7e7",
- "titleBar.inactiveBackground": "#b54c0499",
- "titleBar.inactiveForeground": "#e7e7e799"
- },
- "peacock.color": "#b54c04"
-}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index fe12946..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,99 +0,0 @@
-# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
-# instead of Alpine to avoid DNS resolution issues in production.
-#
-# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
-# https://hub.docker.com/_/ubuntu?tab=tags
-#
-# This file is based on these images:
-#
-# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
-# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20221004-slim - for the release image
-# - https://pkgs.org/ - resource for finding needed packages
-# - Ex: hexpm/elixir:1.14.0-erlang-25.1.2-debian-bullseye-20221004-slim
-#
-ARG ELIXIR_VERSION=1.14.0
-ARG OTP_VERSION=25.1.2
-ARG DEBIAN_VERSION=bullseye-20221004-slim
-
-ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
-ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
-
-FROM ${BUILDER_IMAGE} as builder
-
-# install build dependencies
-RUN apt-get update -y && apt-get install -y build-essential git \
- && apt-get clean && rm -f /var/lib/apt/lists/*_*
-
-# prepare build dir
-WORKDIR /app
-
-# install hex + rebar
-RUN mix local.hex --force && \
- mix local.rebar --force
-
-# set build ENV
-ENV MIX_ENV="prod"
-
-# install mix dependencies
-COPY mix.exs mix.lock ./
-RUN mix deps.get --only $MIX_ENV
-RUN mkdir config
-
-# copy compile-time config files before we compile dependencies
-# to ensure any relevant config change will trigger the dependencies
-# to be re-compiled.
-COPY config/config.exs config/${MIX_ENV}.exs config/
-RUN mix deps.compile
-
-COPY priv priv
-
-COPY lib lib
-
-COPY assets assets
-
-# compile assets
-RUN mix assets.deploy
-
-# Compile the release
-RUN mix compile
-
-# Changes to config/runtime.exs don't require recompiling the code
-COPY config/runtime.exs config/
-
-COPY rel rel
-RUN mix release
-
-# start a new build stage so that the final image will only contain
-# the compiled release and other runtime necessities
-FROM ${RUNNER_IMAGE}
-
-RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
- && apt-get clean && rm -f /var/lib/apt/lists/*_*
-
-# Set the locale
-RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
-
-ENV LANG en_US.UTF-8
-ENV LANGUAGE en_US:en
-ENV LC_ALL en_US.UTF-8
-
-WORKDIR "/app"
-RUN chown nobody /app
-
-# set runner ENV
-ENV MIX_ENV="prod"
-
-# Only copy the final release from the build stage
-COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/malarkey ./
-
-USER nobody
-
-CMD ["/app/bin/server"]
-
-# Appended by flyctl
-ENV ECTO_IPV6 true
-ENV ERL_AFLAGS "-proto_dist inet6_tcp"
-
-# Appended by flyctl
-ENV ECTO_IPV6 true
-ENV ERL_AFLAGS "-proto_dist inet6_tcp"
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 336f0e5..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2022 Fergal Moran
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/README.md b/README.md
index 7d4e721..b956ede 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,119 @@
-# malarkey
-Meaningless talk & nonsense
+# Malarkey - Twitter Clone with Phoenix LiveView
+
+A full-featured Twitter clone built with Elixir, Phoenix LiveView, and PostgreSQL with OAuth authentication.
+
+## Features
+
+### Authentication
+✅ Email/Password registration and login
+✅ OAuth providers (Google, GitHub, Twitter)
+✅ Password reset functionality
+✅ Email confirmation
+✅ Session management with "Remember me" option
+✅ Secure password hashing with Pbkdf2
+✅ User settings management
+
+### Social Features
+✅ Post creation (280 character limit with character counter)
+✅ Reposts and Quote Posts
+✅ Replies/Comments (threaded conversations)
+✅ Like/Unlike posts
+✅ Follow/Unfollow users
+✅ User profiles with bio, location, website
+✅ Profile and header images
+✅ Real-time updates via Phoenix PubSub
+
+### User Interface
+✅ Home timeline feed
+✅ Post detail pages with replies
+✅ User profile pages (posts, replies, media, likes tabs)
+✅ Followers/Following lists
+✅ Notifications page
+✅ Explore/Search page
+✅ Responsive design with Tailwind CSS
+✅ Real-time post composer with character counter
+✅ Interactive post actions (reply, repost, like, share)
+
+### Coming Soon
+🚧 Media upload (images, videos, GIFs)
+🚧 Direct messaging
+🚧 Hashtags and trending topics
+🚧 User mentions (@username)
+🚧 Bookmarks
+🚧 Lists
+🚧 Advanced search
+🚧 Dark mode toggle
+
+## Setup
+
+### Prerequisites
+- Erlang/OTP 28+
+- Elixir 1.19+
+- PostgreSQL 12+
+
+### Installation
+
+1. Install dependencies:
+```bash
+mix deps.get
+```
+
+2. Start PostgreSQL and create the database:
+```bash
+sudo systemctl start postgresql # For systemd-based systems
+mix ecto.create
+mix ecto.migrate
+```
+
+3. (Optional) Set up OAuth provider credentials:
+```bash
+export GOOGLE_CLIENT_ID="your_google_client_id"
+export GOOGLE_CLIENT_SECRET="your_google_client_secret"
+export GITHUB_CLIENT_ID="your_github_client_id"
+export GITHUB_CLIENT_SECRET="your_github_client_secret"
+export TWITTER_CONSUMER_KEY="your_twitter_consumer_key"
+export TWITTER_CONSUMER_SECRET="your_twitter_consumer_secret"
+```
+
+4. Start the Phoenix server:
+```bash
+mix phx.server
+```
+
+Visit [`localhost:4000`](http://localhost:4000) in your browser.
+
+## Database Schema
+
+- **Users**: Authentication, profiles, statistics
+- **Posts**: Content, media, relationships (replies, reposts, quotes)
+- **Likes**: User-post favorites
+- **Follows**: User relationships
+- **OAuth Identities**: Third-party authentication
+- **User Tokens**: Sessions, confirmations, password resets
+
+## Architecture
+
+Built with Phoenix best practices:
+- Context-driven design (Accounts, Social)
+- Ecto migrations with proper constraints
+- UUID primary keys
+- Counter caches for performance
+- Real-time updates with PubSub
+- Secure authentication with Ueberauth
+
+## Next Steps
+
+Complete the LiveView components:
+- User registration/login/settings LiveViews
+- Timeline with post composer
+- Profile pages with tabs
+- Post detail views with replies
+- Implement Tailwind CSS styling with shadcn-inspired components
+
+## Learn more
+
+ * Official website: https://www.phoenixframework.org/
+ * Guides: https://hexdocs.pm/phoenix/overview.html
+ * Docs: https://hexdocs.pm/phoenix
+ * Forum: https://elixirforum.com/c/phoenix-forum
+ * Source: https://github.com/phoenixframework/phoenix
diff --git a/assets/css/app.css b/assets/css/app.css
index 378c8f9..67e74e7 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -2,4 +2,75 @@
@import "tailwindcss/components";
@import "tailwindcss/utilities";
-/* This file is for your main application CSS */
+/* Shadcn-inspired CSS variables */
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 240 10% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 240 10% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 240 10% 3.9%;
+ --primary: 262.1 83.3% 57.8%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 240 4.8% 95.9%;
+ --secondary-foreground: 240 5.9% 10%;
+ --muted: 240 4.8% 95.9%;
+ --muted-foreground: 240 3.8% 46.1%;
+ --accent: 240 4.8% 95.9%;
+ --accent-foreground: 240 5.9% 10%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 5.9% 90%;
+ --input: 240 5.9% 90%;
+ --ring: 262.1 83.3% 57.8%;
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 240 10% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 240 10% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 240 10% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 263.4 70% 50.4%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 240 3.7% 15.9%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 240 3.7% 15.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 263.4 70% 50.4%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Post animations */
+@keyframes slideInFromTop {
+ from {
+ opacity: 0;
+ transform: translateY(-1rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.animate-slide-in {
+ animation: slideInFromTop 0.3s ease-out;
+}
diff --git a/assets/js/app.js b/assets/js/app.js
index 44a8122..20a353e 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -22,13 +22,165 @@ import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
+// Theme handling
+const applyTheme = (theme) => {
+ if (theme === 'system') {
+ const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches
+ document.documentElement.classList.toggle('dark', isDarkMode)
+ } else if (theme === 'dark') {
+ document.documentElement.classList.add('dark')
+ } else {
+ document.documentElement.classList.remove('dark')
+ }
+
+ // Update button states
+ document.querySelectorAll('[data-theme]').forEach(btn => {
+ if (btn.dataset.theme === theme) {
+ btn.classList.remove('border-input')
+ btn.classList.add('border-primary', 'bg-accent')
+ } else {
+ btn.classList.remove('border-primary', 'bg-accent')
+ btn.classList.add('border-input')
+ }
+ })
+}
+
+const initTheme = () => {
+ const theme = localStorage.getItem('theme') || 'system'
+ applyTheme(theme)
+
+ // Listen for system theme changes
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
+ if (localStorage.getItem('theme') === 'system') {
+ document.documentElement.classList.toggle('dark', e.matches)
+ }
+ })
+}
+
+// Initialize theme on page load
+initTheme()
+
+// Hooks for theme management and media upload
+let Hooks = {}
+Hooks.ThemeSelector = {
+ mounted() {
+ this.el.addEventListener("click", () => {
+ const theme = this.el.dataset.theme
+ localStorage.setItem('theme', theme)
+ applyTheme(theme)
+ })
+ }
+}
+
+Hooks.PasteImage = {
+ mounted() {
+ console.log("PasteImage hook mounted on element:", this.el.id)
+
+ // Auto-focus if this is a reply textarea
+ if (this.el.id === "reply-textarea") {
+ this.el.focus()
+ }
+
+ this.el.addEventListener("paste", (e) => {
+ console.log("Paste event detected on textarea:", this.el.id)
+
+ const clipboardData = e.clipboardData || e.originalEvent?.clipboardData || window.clipboardData
+
+ if (!clipboardData) {
+ console.log("No clipboard data available")
+ return
+ }
+
+ const items = clipboardData.items
+ if (!items) {
+ console.log("No clipboard items")
+ return
+ }
+
+ console.log("Clipboard items:", items.length)
+
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i]
+ console.log("Item type:", item.type, item.kind)
+
+ if (item.kind === 'file' && item.type.indexOf("image") !== -1) {
+ e.preventDefault()
+ console.log("Image found in clipboard")
+
+ const blob = item.getAsFile()
+ if (!blob) {
+ console.log("Failed to get file from clipboard item")
+ continue
+ }
+
+ const reader = new FileReader()
+
+ reader.onload = (event) => {
+ console.log("Image loaded, sending to server from textarea:", this.el.id)
+ this.pushEvent("paste_image", {
+ data: event.target.result,
+ type: blob.type,
+ textarea_id: this.el.id
+ })
+ }
+
+ reader.onerror = (error) => {
+ console.error("FileReader error:", error)
+ }
+
+ reader.readAsDataURL(blob)
+ break
+ }
+ }
+ })
+ }
+}
+
+Hooks.PostCard = {
+ mounted() {
+ this.el.addEventListener("click", (e) => {
+ // Check if click is on a link, button, or inside an action area
+ const isInteractiveElement = e.target.closest('a, button')
+
+ if (!isInteractiveElement) {
+ const postUrl = this.el.dataset.postUrl
+ if (postUrl) {
+ this.pushEvent("navigate", {url: postUrl})
+ }
+ }
+ })
+ }
+}
+
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
-let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
+let liveSocket = new LiveSocket("/live", Socket, {
+ longPollFallbackMs: 2500,
+ params: {_csrf_token: csrfToken},
+ hooks: Hooks
+})
+
+// Handle delete-post animation
+window.addEventListener("phx:delete-post", (e) => {
+ const postElement = document.getElementById(e.detail.id)
+ if (postElement) {
+ // Add fade-out classes
+ postElement.classList.add('opacity-0', 'scale-95', 'transition-all', 'duration-300')
+ // The actual removal happens via stream_delete after the animation
+ }
+})
+
+// Stop propagation for nested links in post cards
+document.addEventListener('click', (e) => {
+ const target = e.target.closest('[data-post-link]')
+ if (target) {
+ e.stopPropagation()
+ }
+}, true)
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
-window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200))
-window.addEventListener("phx:page-loading-stop", info => topbar.hide())
+window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
+window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
diff --git a/assets/package-lock.json b/assets/package-lock.json
new file mode 100644
index 0000000..91762d6
--- /dev/null
+++ b/assets/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "assets",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js
index b611701..01e618c 100644
--- a/assets/tailwind.config.js
+++ b/assets/tailwind.config.js
@@ -2,25 +2,118 @@
// https://tailwindcss.com/docs/configuration
const plugin = require("tailwindcss/plugin")
+const fs = require("fs")
+const path = require("path")
module.exports = {
content: [
"./js/**/*.js",
- "../lib/*_web.ex",
- "../lib/*_web/**/*.*ex"
+ "../lib/malarkey_web.ex",
+ "../lib/malarkey_web/**/*.*ex"
],
theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
extend: {
colors: {
brand: "#FD4F00",
- }
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
},
},
plugins: [
require("@tailwindcss/forms"),
- plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
+ // Allows prefixing tailwind classes with LiveView classes to add rules
+ // only when LiveView classes are applied, for example:
+ //
+ //
+ //
plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
- plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]))
+ plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
+
+ // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+ // See your `CoreComponents.icon/1` for more information.
+ //
+ plugin(function({matchComponents, theme}) {
+ let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
+ let values = {}
+ let icons = [
+ ["", "/24/outline"],
+ ["-solid", "/24/solid"],
+ ["-mini", "/20/solid"],
+ ["-micro", "/16/solid"]
+ ]
+ icons.forEach(([suffix, dir]) => {
+ fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+ let name = path.basename(file, ".svg") + suffix
+ values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+ })
+ })
+ matchComponents({
+ "hero": ({name, fullPath}) => {
+ let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+ let size = theme("spacing.6")
+ if (name.endsWith("-mini")) {
+ size = theme("spacing.5")
+ } else if (name.endsWith("-micro")) {
+ size = theme("spacing.4")
+ }
+ return {
+ [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+ "-webkit-mask": `var(--hero-${name})`,
+ "mask": `var(--hero-${name})`,
+ "mask-repeat": "no-repeat",
+ "background-color": "currentColor",
+ "vertical-align": "middle",
+ "display": "inline-block",
+ "width": size,
+ "height": size
+ }
+ }
+ }, {values})
+ })
]
-}
\ No newline at end of file
+}
diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js
index 4176ede..4195727 100644
--- a/assets/vendor/topbar.js
+++ b/assets/vendor/topbar.js
@@ -1,9 +1,7 @@
/**
* @license MIT
- * topbar 1.0.0, 2021-01-06
- * Modifications:
- * - add delayedShow(time) (2022-09-21)
- * http://buunguyen.github.io/topbar
+ * topbar 2.0.0, 2023-02-04
+ * https://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
(function (window, document) {
@@ -98,26 +96,26 @@
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
- delayedShow: function(time) {
+ show: function (delay) {
if (showing) return;
- if (delayTimerId) return;
- delayTimerId = setTimeout(() => topbar.show(), time);
- },
- show: function () {
- if (showing) return;
- showing = true;
- if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
- if (!canvas) createCanvas();
- canvas.style.opacity = 1;
- canvas.style.display = "block";
- topbar.progress(0);
- if (options.autoRun) {
- (function loop() {
- progressTimerId = window.requestAnimationFrame(loop);
- topbar.progress(
- "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
- );
- })();
+ if (delay) {
+ if (delayTimerId) return;
+ delayTimerId = setTimeout(() => topbar.show(), delay);
+ } else {
+ showing = true;
+ if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
+ if (!canvas) createCanvas();
+ canvas.style.opacity = 1;
+ canvas.style.display = "block";
+ topbar.progress(0);
+ if (options.autoRun) {
+ (function loop() {
+ progressTimerId = window.requestAnimationFrame(loop);
+ topbar.progress(
+ "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
+ );
+ })();
+ }
}
},
progress: function (to) {
diff --git a/config/config.exs b/config/config.exs
index 7a2fcf3..a9d7aa7 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -8,18 +8,19 @@
import Config
config :malarkey,
- ecto_repos: [Malarkey.Repo]
+ ecto_repos: [Malarkey.Repo],
+ generators: [timestamp_type: :utc_datetime]
# Configures the endpoint
config :malarkey, MalarkeyWeb.Endpoint,
url: [host: "localhost"],
+ adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: MalarkeyWeb.ErrorHTML, json: MalarkeyWeb.ErrorJSON],
layout: false
],
pubsub_server: Malarkey.PubSub,
- live_view: [signing_salt: "UYdloyCB"],
- check_origin: ["https://malarkey.fergl.ie/"]
+ live_view: [signing_salt: "SH8++5n5"]
# Configures the mailer
#
@@ -32,8 +33,8 @@ config :malarkey, Malarkey.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
- version: "0.14.41",
- default: [
+ version: "0.17.11",
+ malarkey: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
@@ -42,8 +43,8 @@ config :esbuild,
# Configure tailwind (the version is required)
config :tailwind,
- version: "3.1.8",
- default: [
+ version: "3.4.3",
+ malarkey: [
args: ~w(
--config=tailwind.config.js
--input=css/app.css
@@ -60,6 +61,38 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
+# Configure Ueberauth for OAuth providers
+config :ueberauth, Ueberauth,
+ providers: [
+ google: {Ueberauth.Strategy.Google, []},
+ github: {Ueberauth.Strategy.Github, []},
+ twitter: {Ueberauth.Strategy.Twitter, []},
+ identity: {Ueberauth.Strategy.Identity, [
+ callback_methods: ["POST"],
+ uid_field: :email,
+ nickname_field: :email
+ ]}
+ ]
+
+# Configure OAuth provider credentials (move to runtime.exs for production)
+config :ueberauth, Ueberauth.Strategy.Google.OAuth,
+ client_id: System.get_env("GOOGLE_CLIENT_ID"),
+ client_secret: System.get_env("GOOGLE_CLIENT_SECRET")
+
+config :ueberauth, Ueberauth.Strategy.Github.OAuth,
+ client_id: System.get_env("GITHUB_CLIENT_ID"),
+ client_secret: System.get_env("GITHUB_CLIENT_SECRET")
+
+config :ueberauth, Ueberauth.Strategy.Twitter.OAuth,
+ consumer_key: System.get_env("TWITTER_CONSUMER_KEY"),
+ consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET")
+
+
+# Configure local media upload directory and Giphy API
+config :malarkey,
+ media_upload_dir: "priv/static/uploads",
+ giphy_api_key: System.get_env("GIPHY_API_KEY") || ""
+
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
diff --git a/config/dev.exs b/config/dev.exs
index 84b0b7b..fe8e2ee 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -14,8 +14,8 @@ config :malarkey, Malarkey.Repo,
# debugging and code reloading.
#
# The watchers configuration can be used to run external
-# watchers to your application. For example, we use it
-# with esbuild to bundle .js and .css sources.
+# watchers to your application. For example, we can use it
+# to bundle .js and .css sources.
config :malarkey, MalarkeyWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
@@ -23,10 +23,10 @@ config :malarkey, MalarkeyWeb.Endpoint,
check_origin: false,
code_reloader: true,
debug_errors: true,
- secret_key_base: "7J/3rVbYDSqC9cp4x/IxzhIr3OBHaQXso8CbFf0j4GfyfwCIpvc5ghgO7vPuBCwP",
+ secret_key_base: "VAjY0tHhesCtCCj8LPSuVu9hdCofA63uyNJT6PAwcGHxlHYTUMDax203Bbm7YD0o",
watchers: [
- esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
- tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
+ esbuild: {Esbuild, :install_and_run, [:malarkey, ~w(--sourcemap=inline --watch)]},
+ tailwind: {Tailwind, :install_and_run, [:malarkey, ~w(--watch)]}
]
# ## SSL Support
@@ -56,10 +56,9 @@ config :malarkey, MalarkeyWeb.Endpoint,
config :malarkey, MalarkeyWeb.Endpoint,
live_reload: [
patterns: [
- ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
+ ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
- ~r"lib/malarkey_web/(live|views)/.*(ex)$",
- ~r"lib/malarkey_web/templates/.*(eex)$"
+ ~r"lib/malarkey_web/(controllers|live|components)/.*(ex|heex)$"
]
]
@@ -76,5 +75,11 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
+config :phoenix_live_view,
+ # Include HEEx debug annotations as HTML comments in rendered markup
+ debug_heex_annotations: true,
+ # Enable helpful, but potentially expensive runtime checks
+ enable_expensive_runtime_checks: true
+
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false
diff --git a/config/prod.exs b/config/prod.exs
index 5ecbad4..de21e9a 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -1,18 +1,17 @@
import Config
-# For production, don't forget to configure the url host
-# to something meaningful, Phoenix uses this information
-# when generating URLs.
-
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
-# manifest is generated by the `mix phx.digest` task,
+# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :malarkey, MalarkeyWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
# Configures Swoosh API Client
-config :swoosh, :api_client, Malarkey.Finch
+config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Malarkey.Finch
+
+# Disable Swoosh Local Memory Storage
+config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
diff --git a/config/runtime.exs b/config/runtime.exs
index 8528021..83aaa5c 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -28,7 +28,7 @@ if config_env() == :prod do
For example: ecto://USER:PASS@HOST/DATABASE
"""
- maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
+ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :malarkey, Malarkey.Repo,
# ssl: true,
@@ -51,12 +51,14 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
+ config :malarkey, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
+
config :malarkey, MalarkeyWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
- # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
+ # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
@@ -87,8 +89,8 @@ if config_env() == :prod do
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
- # We also recommend setting `force_ssl` in your endpoint, ensuring
- # no data is ever sent via http, always redirecting to https:
+ # We also recommend setting `force_ssl` in your config/prod.exs,
+ # ensuring no data is ever sent via http, always redirecting to https:
#
# config :malarkey, MalarkeyWeb.Endpoint,
# force_ssl: [hsts: true]
diff --git a/config/test.exs b/config/test.exs
index f46ea68..81df08f 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,8 +1,5 @@
import Config
-# Only in tests, remove the complexity from the password hashing algorithm
-config :bcrypt_elixir, :log_rounds, 1
-
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
@@ -14,19 +11,19 @@ config :malarkey, Malarkey.Repo,
hostname: "localhost",
database: "malarkey_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
- pool_size: 10
+ pool_size: System.schedulers_online() * 2
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :malarkey, MalarkeyWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
- secret_key_base: "2/EjqngGC/YAYuEnyFk4BIRyMhBs29VtzndNLvvdYeiegOFctblOTc4i5m8z4GvS",
+ secret_key_base: "sXXdDtdo1Pux/ImB+2oaTw2XwqA0vLUnPs6Pf86i0VmYRAR518hQJ5tktY7DEn6+",
server: false
-# In test we don't send emails.
+# In test we don't send emails
config :malarkey, Malarkey.Mailer, adapter: Swoosh.Adapters.Test
-# Disable swoosh api client as it is only required for production adapters.
+# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
@@ -34,3 +31,7 @@ config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
+
+# Enable helpful, but potentially expensive runtime checks
+config :phoenix_live_view,
+ enable_expensive_runtime_checks: true
diff --git a/fly.toml b/fly.toml
deleted file mode 100644
index 048cb86..0000000
--- a/fly.toml
+++ /dev/null
@@ -1,43 +0,0 @@
-# fly.toml file generated for malarkey on 2022-11-25T18:51:01Z
-
-app = "malarkey"
-kill_signal = "SIGTERM"
-kill_timeout = 5
-processes = []
-
-[deploy]
- release_command = "/app/bin/migrate"
-
-[env]
- PHX_HOST = "malarkey.fly.dev"
- PORT = "8080"
-
-[experimental]
- allowed_public_ports = []
- auto_rollback = true
-
-[[services]]
- http_checks = []
- internal_port = 8080
- processes = ["app"]
- protocol = "tcp"
- script_checks = []
- [services.concurrency]
- hard_limit = 25
- soft_limit = 20
- type = "connections"
-
- [[services.ports]]
- force_https = true
- handlers = ["http"]
- port = 80
-
- [[services.ports]]
- handlers = ["tls", "http"]
- port = 443
-
- [[services.tcp_checks]]
- grace_period = "1s"
- interval = "15s"
- restart_limit = 0
- timeout = "2s"
diff --git a/lib/malarkey/accounts.ex b/lib/malarkey/accounts.ex
index 7609b27..b72c206 100644
--- a/lib/malarkey/accounts.ex
+++ b/lib/malarkey/accounts.ex
@@ -6,7 +6,7 @@ defmodule Malarkey.Accounts do
import Ecto.Query, warn: false
alias Malarkey.Repo
- alias Malarkey.Accounts.{User, UserToken, UserNotifier}
+ alias Malarkey.Accounts.{User, UserToken, OAuthIdentity}
## Database getters
@@ -58,7 +58,23 @@ defmodule Malarkey.Accounts do
** (Ecto.NoResultsError)
"""
- def get_user!(id), do: Repo.get!(User, id)
+ def get_user!(id) do
+ Repo.get!(User, id)
+ end
+
+ @doc """
+ Gets a user by username.
+ """
+ def get_user_by_username(username) when is_binary(username) do
+ Repo.get_by(User, username: username)
+ end
+
+ @doc """
+ Gets a user by username with preloaded associations.
+ """
+ def get_user_by_username_with_stats(username) when is_binary(username) do
+ Repo.get_by(User, username: username)
+ end
## User registration
@@ -138,8 +154,8 @@ defmodule Malarkey.Accounts do
context = "change:#{user.email}"
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
- %UserToken{sent_to: email} <- Repo.one(query),
- {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
+ %User{} = user <- Repo.one(query),
+ {:ok, _} <- Repo.transaction(user_email_multi(user, user.email, context)) do
:ok
else
_ -> :error
@@ -157,12 +173,12 @@ defmodule Malarkey.Accounts do
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
end
- @doc ~S"""
+ @doc """
Delivers the update email instructions to the given user.
## Examples
- iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1})")
+ iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/\#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
@@ -171,7 +187,8 @@ defmodule Malarkey.Accounts do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
Repo.insert!(user_token)
- UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
+ # UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
+ {:ok, %{to: user.email, body: "Update email token: #{encoded_token}"}}
end
@doc """
@@ -215,6 +232,31 @@ defmodule Malarkey.Accounts do
end
end
+ @doc """
+ Updates the user profile.
+
+ ## Examples
+
+ iex> update_user_profile(user, %{display_name: ...})
+ {:ok, %User{}}
+
+ iex> update_user_profile(user, %{display_name: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_user_profile(%User{} = user, attrs) do
+ user
+ |> User.profile_changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user profile.
+ """
+ def change_user_profile(%User{} = user, attrs \\ %{}) do
+ User.profile_changeset(user, attrs)
+ end
+
## Session
@doc """
@@ -244,15 +286,15 @@ defmodule Malarkey.Accounts do
## Confirmation
- @doc ~S"""
+ @doc """
Delivers the confirmation email instructions to the given user.
## Examples
- iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}"))
+ iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/\#{&1}"))
{:ok, %{to: ..., body: ...}}
- iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}"))
+ iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/\#{&1}"))
{:error, :already_confirmed}
"""
@@ -263,7 +305,8 @@ defmodule Malarkey.Accounts do
else
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
Repo.insert!(user_token)
- UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
+ # UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
+ {:ok, %{to: user.email, body: "Confirmation token: #{encoded_token}"}}
end
end
@@ -291,12 +334,12 @@ defmodule Malarkey.Accounts do
## Reset password
- @doc ~S"""
+ @doc """
Delivers the reset password email to the given user.
## Examples
- iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
+ iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/\#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
@@ -304,7 +347,8 @@ defmodule Malarkey.Accounts do
when is_function(reset_password_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
Repo.insert!(user_token)
- UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
+ # UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
+ {:ok, %{to: user.email, body: "Reset token: #{encoded_token}"}}
end
@doc """
@@ -350,4 +394,81 @@ defmodule Malarkey.Accounts do
{:error, :user, changeset, _} -> {:error, changeset}
end
end
+
+ ## OAuth
+
+ @doc """
+ Gets or creates a user from OAuth provider data.
+ """
+ def get_or_create_oauth_user(provider, auth) do
+ provider = to_string(provider)
+ uid = auth.uid
+ email = auth.info.email
+ username = generate_username(auth.info.nickname || auth.info.name || email)
+
+ case Repo.get_by(OAuthIdentity, provider: provider, provider_uid: uid) do
+ nil ->
+ # Create new user and OAuth identity
+ create_oauth_user(provider, auth, username, email)
+
+ identity ->
+ # Return existing user
+ {:ok, Repo.preload(identity, :user).user}
+ end
+ end
+
+ defp create_oauth_user(provider, auth, username, email) do
+ Ecto.Multi.new()
+ |> Ecto.Multi.insert(:user, fn _ ->
+ %User{}
+ |> Ecto.Changeset.change(%{
+ email: email,
+ username: username,
+ display_name: auth.info.name,
+ avatar_url: auth.info.image,
+ confirmed_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+ })
+ end)
+ |> Ecto.Multi.insert(:identity, fn %{user: user} ->
+ %OAuthIdentity{}
+ |> OAuthIdentity.changeset(%{
+ user_id: user.id,
+ provider: provider,
+ provider_uid: auth.uid,
+ provider_email: email,
+ provider_login: auth.info.nickname,
+ provider_token: auth.credentials.token,
+ provider_meta: %{
+ name: auth.info.name,
+ image: auth.info.image
+ }
+ })
+ end)
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, _operation, changeset, _changes} -> {:error, changeset}
+ end
+ end
+
+ defp generate_username(base) when is_binary(base) do
+ base
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9_]/, "")
+ |> String.slice(0, 10)
+ |> ensure_unique_username()
+ end
+
+ defp generate_username(_), do: ensure_unique_username("user")
+
+ defp ensure_unique_username(username) do
+ case Repo.get_by(User, username: username) do
+ nil ->
+ username
+
+ _ ->
+ random_suffix = :rand.uniform(9999)
+ ensure_unique_username("#{username}#{random_suffix}")
+ end
+ end
end
diff --git a/lib/malarkey/accounts/oauth_identity.ex b/lib/malarkey/accounts/oauth_identity.ex
new file mode 100644
index 0000000..aaac05b
--- /dev/null
+++ b/lib/malarkey/accounts/oauth_identity.ex
@@ -0,0 +1,27 @@
+defmodule Malarkey.Accounts.OAuthIdentity do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "oauth_identities" do
+ field :provider, :string
+ field :provider_uid, :string
+ field :provider_email, :string
+ field :provider_login, :string
+ field :provider_token, :string
+ field :provider_meta, :map
+
+ belongs_to :user, Malarkey.Accounts.User
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(oauth_identity, attrs) do
+ oauth_identity
+ |> cast(attrs, [:provider, :provider_uid, :provider_email, :provider_login, :provider_token, :provider_meta, :user_id])
+ |> validate_required([:provider, :provider_uid])
+ |> unique_constraint([:provider, :provider_uid])
+ end
+end
diff --git a/lib/malarkey/accounts/user.ex b/lib/malarkey/accounts/user.ex
index 39a7217..ed5d73b 100644
--- a/lib/malarkey/accounts/user.ex
+++ b/lib/malarkey/accounts/user.ex
@@ -1,36 +1,37 @@
defmodule Malarkey.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
- alias Malarkey.Timeline.Post
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
schema "users" do
field :email, :string
+ field :username, :string
+ field :display_name, :string
+ field :bio, :string
+ field :location, :string
+ field :website, :string
+ field :avatar_url, :string
+ field :header_url, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
+ field :verified, :boolean, default: false
+ field :followers_count, :integer, default: 0
+ field :following_count, :integer, default: 0
+ field :posts_count, :integer, default: 0
field :confirmed_at, :naive_datetime
- field :username, :string
- field :fullname, :string
- many_to_many(
- :likes,
- Post,
- join_through: Malarkey.Timeline.PostUserLike,
- on_replace: :delete
- )
+ has_many :posts, Malarkey.Social.Post
+ has_many :likes, Malarkey.Social.Like
+ has_many :oauth_identities, Malarkey.Accounts.OAuthIdentity
- many_to_many(
- :dislikes,
- Post,
- join_through: Malarkey.Timeline.PostUserDislike,
- on_replace: :delete
- )
+ many_to_many :followers, Malarkey.Accounts.User,
+ join_through: "follows",
+ join_keys: [following_id: :id, follower_id: :id]
- many_to_many(
- :reposts,
- Post,
- join_through: Malarkey.Timeline.PostUserReposts,
- on_replace: :delete
- )
+ many_to_many :following, Malarkey.Accounts.User,
+ join_through: "follows",
+ join_keys: [follower_id: :id, following_id: :id]
timestamps()
end
@@ -60,8 +61,9 @@ defmodule Malarkey.Accounts.User do
"""
def registration_changeset(user, attrs, opts \\ []) do
user
- |> cast(attrs, [:email, :password])
+ |> cast(attrs, [:email, :password, :username, :display_name])
|> validate_email(opts)
+ |> validate_username()
|> validate_password(opts)
end
@@ -73,10 +75,21 @@ defmodule Malarkey.Accounts.User do
|> maybe_validate_unique_email(opts)
end
+ defp validate_username(changeset) do
+ changeset
+ |> validate_required([:username])
+ |> validate_length(:username, min: 3, max: 15)
+ |> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/,
+ message: "can only contain letters, numbers, and underscores")
+ |> unsafe_validate_unique(:username, Malarkey.Repo)
+ |> unique_constraint(:username)
+ end
+
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
- |> validate_length(:password, min: 12, max: 72)
+ |> validate_length(:password, min: 8, max: 72)
+ # Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
@@ -91,7 +104,8 @@ defmodule Malarkey.Accounts.User do
changeset
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
- |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
+ # Hashing could be done with `Bcrypt`, but we use `Pbkdf2` for security.
+ |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
@@ -142,6 +156,18 @@ defmodule Malarkey.Accounts.User do
|> validate_password(opts)
end
+ @doc """
+ A user changeset for profile updates.
+ """
+ def profile_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:display_name, :bio, :location, :website, :avatar_url, :header_url])
+ |> validate_length(:display_name, max: 50)
+ |> validate_length(:bio, max: 160)
+ |> validate_length(:location, max: 30)
+ |> validate_length(:website, max: 100)
+ end
+
@doc """
Confirms the account by setting `confirmed_at`.
"""
@@ -154,15 +180,15 @@ defmodule Malarkey.Accounts.User do
Verifies the password.
If there is no user or the user doesn't have a password, we call
- `Bcrypt.no_user_verify/0` to avoid timing attacks.
+ `Pbkdf2.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Malarkey.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
- Bcrypt.verify_pass(password, hashed_password)
+ Pbkdf2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
- Bcrypt.no_user_verify()
+ Pbkdf2.no_user_verify()
false
end
diff --git a/lib/malarkey/accounts/user_notifier.ex b/lib/malarkey/accounts/user_notifier.ex
deleted file mode 100644
index 41ba2a3..0000000
--- a/lib/malarkey/accounts/user_notifier.ex
+++ /dev/null
@@ -1,79 +0,0 @@
-defmodule Malarkey.Accounts.UserNotifier do
- import Swoosh.Email
-
- alias Malarkey.Mailer
-
- # Delivers the email using the application mailer.
- defp deliver(recipient, subject, body) do
- email =
- new()
- |> to(recipient)
- |> from({"Malarkey", "contact@example.com"})
- |> subject(subject)
- |> text_body(body)
-
- with {:ok, _metadata} <- Mailer.deliver(email) do
- {:ok, email}
- end
- end
-
- @doc """
- Deliver instructions to confirm account.
- """
- def deliver_confirmation_instructions(user, url) do
- deliver(user.email, "Confirmation instructions", """
-
- ==============================
-
- Hi #{user.email},
-
- You can confirm your account by visiting the URL below:
-
- #{url}
-
- If you didn't create an account with us, please ignore this.
-
- ==============================
- """)
- end
-
- @doc """
- Deliver instructions to reset a user password.
- """
- def deliver_reset_password_instructions(user, url) do
- deliver(user.email, "Reset password instructions", """
-
- ==============================
-
- Hi #{user.email},
-
- You can reset your password by visiting the URL below:
-
- #{url}
-
- If you didn't request this change, please ignore this.
-
- ==============================
- """)
- end
-
- @doc """
- Deliver instructions to update a user email.
- """
- def deliver_update_email_instructions(user, url) do
- deliver(user.email, "Update email instructions", """
-
- ==============================
-
- Hi #{user.email},
-
- You can change your email by visiting the URL below:
-
- #{url}
-
- If you didn't request this change, please ignore this.
-
- ==============================
- """)
- end
-end
diff --git a/lib/malarkey/accounts/user_token.ex b/lib/malarkey/accounts/user_token.ex
index 3093ec7..b59d3cf 100644
--- a/lib/malarkey/accounts/user_token.ex
+++ b/lib/malarkey/accounts/user_token.ex
@@ -1,8 +1,9 @@
defmodule Malarkey.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
- alias Malarkey.Accounts.UserToken
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
@hash_algorithm :sha256
@rand_size 32
@@ -28,22 +29,12 @@ defmodule Malarkey.Accounts.UserToken do
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
- though Phoenix already provides a session cookie, is because
- Phoenix' default session cookies are not persisted, they are
- simply signed and potentially encrypted. This means they are
- valid indefinitely, unless you change the signing/encryption
- salt.
-
- Therefore, storing them allows individual user
- sessions to be expired. The token system can also be extended
- to store additional data, such as the device used for logging in.
- You could then use this information to display all valid sessions
- and devices in the UI and allow users to explicitly expire any
- session they deem invalid.
+ though Phoenix already provides a session cookie, is to allow
+ users to explicitly invalidate any of their open sessions.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
- {token, %UserToken{token: token, context: "session", user_id: user.id}}
+ {token, %Malarkey.Accounts.UserToken{token: token, context: "session", user_id: user.id}}
end
@doc """
@@ -86,7 +77,7 @@ defmodule Malarkey.Accounts.UserToken do
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
- %UserToken{
+ %Malarkey.Accounts.UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
@@ -163,17 +154,17 @@ defmodule Malarkey.Accounts.UserToken do
Returns the token struct for the given token value and context.
"""
def token_and_context_query(token, context) do
- from UserToken, where: [token: ^token, context: ^context]
+ from Malarkey.Accounts.UserToken, where: [token: ^token, context: ^context]
end
@doc """
Gets all tokens for the given user for the given contexts.
"""
def user_and_contexts_query(user, :all) do
- from t in UserToken, where: t.user_id == ^user.id
+ from t in Malarkey.Accounts.UserToken, where: t.user_id == ^user.id
end
def user_and_contexts_query(user, [_ | _] = contexts) do
- from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
+ from t in Malarkey.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
end
end
diff --git a/lib/malarkey/application.ex b/lib/malarkey/application.ex
index 06fb082..ca16c20 100644
--- a/lib/malarkey/application.ex
+++ b/lib/malarkey/application.ex
@@ -8,18 +8,16 @@ defmodule Malarkey.Application do
@impl true
def start(_type, _args) do
children = [
- # Start the Telemetry supervisor
MalarkeyWeb.Telemetry,
- # Start the Ecto repository
Malarkey.Repo,
- # Start the PubSub system
+ {DNSCluster, query: Application.get_env(:malarkey, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Malarkey.PubSub},
- # Start Finch
+ # Start the Finch HTTP client for sending emails
{Finch, name: Malarkey.Finch},
- # Start the Endpoint (http/https)
- MalarkeyWeb.Endpoint
# Start a worker by calling: Malarkey.Worker.start_link(arg)
- # {Malarkey.Worker, arg}
+ # {Malarkey.Worker, arg},
+ # Start to serve requests, typically the last entry
+ MalarkeyWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
diff --git a/lib/malarkey/giphy.ex b/lib/malarkey/giphy.ex
new file mode 100644
index 0000000..72be82e
--- /dev/null
+++ b/lib/malarkey/giphy.ex
@@ -0,0 +1,81 @@
+defmodule Malarkey.Giphy do
+ @moduledoc """
+ Client for Giphy API integration.
+ """
+
+ @api_key Application.compile_env(:malarkey, :giphy_api_key, "")
+ @base_url "https://api.giphy.com/v1/gifs"
+
+ @doc """
+ Search for GIFs on Giphy.
+ """
+ def search(query, limit \\ 25, offset \\ 0) do
+ params = %{
+ api_key: @api_key,
+ q: query,
+ limit: limit,
+ offset: offset,
+ rating: "g",
+ lang: "en"
+ }
+
+ case HTTPoison.get("#{@base_url}/search", [], params: params) do
+ {:ok, %{status_code: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"data" => gifs}} ->
+ {:ok, parse_gifs(gifs)}
+ {:error, _} = error ->
+ error
+ end
+
+ {:ok, %{status_code: status}} ->
+ {:error, "Giphy API returned status #{status}"}
+
+ {:error, %{reason: reason}} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Get trending GIFs from Giphy.
+ """
+ def trending(limit \\ 25, offset \\ 0) do
+ params = %{
+ api_key: @api_key,
+ limit: limit,
+ offset: offset,
+ rating: "g"
+ }
+
+ case HTTPoison.get("#{@base_url}/trending", [], params: params) do
+ {:ok, %{status_code: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"data" => gifs}} ->
+ {:ok, parse_gifs(gifs)}
+ {:error, _} = error ->
+ error
+ end
+
+ {:ok, %{status_code: status}} ->
+ {:error, "Giphy API returned status #{status}"}
+
+ {:error, %{reason: reason}} ->
+ {:error, reason}
+ end
+ end
+
+ # Private functions
+
+ defp parse_gifs(gifs) do
+ Enum.map(gifs, fn gif ->
+ %{
+ id: gif["id"],
+ title: gif["title"],
+ url: get_in(gif, ["images", "fixed_height", "url"]),
+ preview_url: get_in(gif, ["images", "fixed_height_small", "url"]),
+ width: get_in(gif, ["images", "fixed_height", "width"]),
+ height: get_in(gif, ["images", "fixed_height", "height"])
+ }
+ end)
+ end
+end
diff --git a/lib/malarkey/media.ex b/lib/malarkey/media.ex
new file mode 100644
index 0000000..a069937
--- /dev/null
+++ b/lib/malarkey/media.ex
@@ -0,0 +1,90 @@
+defmodule Malarkey.Media do
+ @moduledoc """
+ Context for handling media uploads to the local filesystem.
+ """
+
+ @upload_dir "priv/static/uploads"
+
+ @doc """
+ Uploads a file to the local filesystem and returns the public URL.
+ """
+ def upload_file(file_path, content_type, user_id) do
+ file_name = generate_filename(file_path, user_id)
+ dest_path = Path.join(@upload_dir, file_name)
+ File.mkdir_p!(Path.dirname(dest_path))
+ case File.cp(file_path, dest_path) do
+ :ok -> {:ok, get_public_url(file_name)}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc """
+ Uploads binary data (from paste) to the local filesystem.
+ """
+ def upload_binary(binary, content_type, user_id, extension \\ ".png") do
+ file_name = generate_binary_filename(user_id, extension)
+ dest_path = Path.join(@upload_dir, file_name)
+ File.mkdir_p!(Path.dirname(dest_path))
+ case File.write(dest_path, binary) do
+ :ok -> {:ok, get_public_url(file_name)}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc """
+ Determines media type from content type or URL.
+ """
+ def get_media_type(content_type) when is_binary(content_type) do
+ cond do
+ String.starts_with?(content_type, "image/") -> "image"
+ String.starts_with?(content_type, "video/") -> "video"
+ true -> "unknown"
+ end
+ end
+
+ def get_media_type(_), do: "unknown"
+
+ @doc """
+ Validates file size (10MB for images, 50MB for videos).
+ """
+ def validate_file_size(size, type) do
+ max_size = case type do
+ "image" -> 10 * 1024 * 1024 # 10MB
+ "video" -> 50 * 1024 * 1024 # 50MB
+ _ -> 10 * 1024 * 1024
+ end
+
+ if size <= max_size do
+ :ok
+ else
+ {:error, "File too large. Maximum size is #{format_bytes(max_size)}"}
+ end
+ end
+
+ # Private functions
+
+ defp generate_filename(file_path, user_id) do
+ extension = Path.extname(file_path)
+ timestamp = DateTime.utc_now() |> DateTime.to_unix()
+ random = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
+ "#{user_id}/#{timestamp}_#{random}#{extension}"
+ end
+
+ defp generate_binary_filename(user_id, extension) do
+ timestamp = DateTime.utc_now() |> DateTime.to_unix()
+ random = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
+ "#{user_id}/#{timestamp}_#{random}#{extension}"
+ end
+
+ defp get_public_url(file_name) do
+ "/uploads/#{file_name}"
+ end
+
+ defp format_bytes(bytes) do
+ cond do
+ bytes >= 1024 * 1024 -> "#{div(bytes, 1024 * 1024)}MB"
+ bytes >= 1024 -> "#{div(bytes, 1024)}KB"
+ true -> "#{bytes}B"
+ end
+ end
+end
diff --git a/lib/malarkey/release.ex b/lib/malarkey/release.ex
deleted file mode 100644
index 7ae6d63..0000000
--- a/lib/malarkey/release.ex
+++ /dev/null
@@ -1,28 +0,0 @@
-defmodule Malarkey.Release do
- @moduledoc """
- Used for executing DB release tasks when run in production without Mix
- installed.
- """
- @app :malarkey
-
- def migrate do
- load_app()
-
- for repo <- repos() do
- {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
- end
- end
-
- def rollback(repo, version) do
- load_app()
- {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
- end
-
- defp repos do
- Application.fetch_env!(@app, :ecto_repos)
- end
-
- defp load_app do
- Application.load(@app)
- end
-end
diff --git a/lib/malarkey/social.ex b/lib/malarkey/social.ex
new file mode 100644
index 0000000..3aad1af
--- /dev/null
+++ b/lib/malarkey/social.ex
@@ -0,0 +1,575 @@
+defmodule Malarkey.Social do
+ @moduledoc """
+ The Social context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Malarkey.Repo
+
+ alias Malarkey.Social.{Post, Like, Follow}
+ alias Malarkey.Accounts.User
+
+ @doc """
+ Returns the list of posts for the home timeline.
+ Includes posts from users that the given user follows.
+ """
+ def list_timeline_posts(user, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+
+ following_ids =
+ from(f in Follow,
+ where: f.follower_id == ^user.id,
+ select: f.following_id
+ )
+ |> Repo.all()
+
+ user_ids = [user.id | following_ids]
+
+ from(t in Post,
+ where: t.user_id in ^user_ids,
+ order_by: [desc: t.inserted_at],
+ limit: ^limit,
+ preload: [:user, :reply_to, :repost_of, :quote_post]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Returns the list of posts for a specific user.
+ """
+ def list_user_posts(user, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+
+ from(t in Post,
+ where: t.user_id == ^user.id and is_nil(t.repost_of_id),
+ order_by: [desc: t.inserted_at],
+ limit: ^limit,
+ preload: [:user, :reply_to, :quote_post]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Returns the list of posts with media for a specific user.
+ """
+ def list_user_media_posts(user, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+
+ from(t in Post,
+ where: t.user_id == ^user.id and fragment("cardinality(?) > 0", t.media_urls),
+ order_by: [desc: t.inserted_at],
+ limit: ^limit,
+ preload: [:user]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Returns the list of posts liked by a specific user.
+ """
+ def list_user_liked_posts(user, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+
+ from(t in Post,
+ join: l in Like,
+ on: l.post_id == t.id,
+ where: l.user_id == ^user.id,
+ order_by: [desc: l.inserted_at],
+ limit: ^limit,
+ preload: [:user, :reply_to, :quote_post]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets a single post.
+
+ Raises `Ecto.NoResultsError` if the Post does not exist.
+
+ ## Examples
+
+ iex> get_post!(123)
+ %Post{}
+
+ iex> get_post!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ @doc """
+ Gets a single post, raising if not found.
+ Accepts both string UUIDs and Ecto.UUID binaries.
+ """
+ def get_post!(id) when is_binary(id) do
+ case Ecto.UUID.cast(id) do
+ {:ok, uuid} ->
+ Post
+ |> Repo.get!(uuid)
+ |> Repo.preload([:user, :reply_to, :repost_of, :quote_post])
+ :error ->
+ raise Ecto.NoResultsError, queryable: Post
+ end
+ end
+
+ def get_post!(id) do
+ Post
+ |> Repo.get!(id)
+ |> Repo.preload([:user, :reply_to, :repost_of, :quote_post])
+ end
+
+ @doc """
+ Gets a single post without raising.
+
+ Returns `nil` if the Post does not exist.
+ Accepts both string UUIDs and Ecto.UUID binaries.
+
+ ## Examples
+
+ iex> get_post("123e4567-e89b-12d3-a456-426614174000")
+ %Post{}
+
+ iex> get_post("invalid")
+ nil
+
+ """
+ def get_post(id) when is_binary(id) do
+ case Ecto.UUID.cast(id) do
+ {:ok, uuid} ->
+ case Repo.get(Post, uuid) do
+ nil -> nil
+ post -> Repo.preload(post, [:user, :reply_to, :repost_of, :quote_post])
+ end
+ :error -> nil
+ end
+ end
+
+ def get_post(id) do
+ case Repo.get(Post, id) do
+ nil -> nil
+ post -> Repo.preload(post, [:user, :reply_to, :repost_of, :quote_post])
+ end
+ end
+
+ @doc """
+ Gets post replies.
+ """
+ def list_post_replies(post, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 100)
+
+ from(t in Post,
+ where: t.reply_to_id == ^post.id,
+ order_by: [asc: t.inserted_at],
+ limit: ^limit,
+ preload: [:user]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets threaded replies for a post with nested structure.
+ Returns a map of post_id => replies for building thread trees.
+ Accepts both string UUIDs and Ecto.UUID binaries.
+ """
+ def get_threaded_replies(post_id, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 500)
+
+ # Convert string UUID to binary format for Postgres
+ uuid = case Ecto.UUID.dump(post_id) do
+ {:ok, binary_uuid} -> binary_uuid
+ :error -> post_id # Already in binary format
+ end
+
+ # Get all replies in the thread recursively
+ query = """
+ WITH RECURSIVE reply_tree AS (
+ -- Base case: direct replies to the post
+ SELECT p.*, 0 as depth, ARRAY[p.id] as path
+ FROM posts p
+ WHERE p.reply_to_id = $1
+
+ UNION ALL
+
+ -- Recursive case: replies to replies
+ SELECT p.*, rt.depth + 1, rt.path || p.id
+ FROM posts p
+ INNER JOIN reply_tree rt ON p.reply_to_id = rt.id
+ WHERE NOT p.id = ANY(rt.path) -- Prevent cycles
+ AND rt.depth < 10 -- Limit depth
+ )
+ SELECT * FROM reply_tree ORDER BY path LIMIT $2
+ """
+
+ result = Ecto.Adapters.SQL.query!(Repo, query, [uuid, limit])
+
+ # Convert results to Post structs and preload associations
+ reply_ids = Enum.map(result.rows, fn row -> Enum.at(row, 0) end)
+
+ from(p in Post,
+ where: p.id in ^reply_ids,
+ preload: [:user, :reply_to]
+ )
+ |> Repo.all()
+ |> Enum.group_by(& &1.reply_to_id)
+ end
+
+ @doc """
+ Creates a post.
+
+ ## Examples
+
+ iex> create_post(%{field: value})
+ {:ok, %Post{}}
+
+ iex> create_post(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_post(attrs \\ %{}) do
+ result =
+ %Post{}
+ |> Post.changeset(attrs)
+ |> Repo.insert()
+
+ case result do
+ {:ok, post} ->
+ # Increment user's posts count
+ from(u in User, where: u.id == ^post.user_id)
+ |> Repo.update_all(inc: [posts_count: 1])
+
+ # If it's a reply, increment the parent's replies count
+ if post.reply_to_id do
+ from(t in Post, where: t.id == ^post.reply_to_id)
+ |> Repo.update_all(inc: [replies_count: 1])
+ end
+
+ # Broadcast the new post
+ Phoenix.PubSub.broadcast(
+ Malarkey.PubSub,
+ "posts:new",
+ {:new_post, Repo.preload(post, [:user, :reply_to, :quote_post])}
+ )
+
+ {:ok, Repo.preload(post, [:user, :reply_to, :quote_post])}
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Checks if a user has reposted a post.
+ """
+ def reposted_by_user?(user_id, post_id) do
+ from(t in Post,
+ where: t.user_id == ^user_id and t.repost_of_id == ^post_id
+ )
+ |> Repo.exists?()
+ end
+
+ @doc """
+ Creates a repost.
+ """
+ def create_repost(user_id, post_id) do
+ # Check if already reposted
+ existing =
+ from(t in Post,
+ where: t.user_id == ^user_id and t.repost_of_id == ^post_id
+ )
+ |> Repo.one()
+
+ if existing do
+ {:error, :already_reposted}
+ else
+ result =
+ %Post{}
+ |> Post.repost_changeset(%{user_id: user_id, repost_of_id: post_id})
+ |> Repo.insert()
+
+ case result do
+ {:ok, repost} ->
+ # Increment the original post's reposts count
+ from(t in Post, where: t.id == ^post_id)
+ |> Repo.update_all(inc: [reposts_count: 1])
+
+ # Increment user's posts count
+ from(u in User, where: u.id == ^user_id)
+ |> Repo.update_all(inc: [posts_count: 1])
+
+ {:ok, Repo.preload(repost, [:user, :repost_of])}
+
+ error ->
+ error
+ end
+ end
+ end
+
+ @doc """
+ Deletes a repost.
+ """
+ def delete_repost(user_id, post_id) do
+ from(t in Post,
+ where: t.user_id == ^user_id and t.repost_of_id == ^post_id
+ )
+ |> Repo.one()
+ |> case do
+ nil ->
+ {:error, :not_found}
+
+ repost ->
+ Repo.delete(repost)
+
+ # Decrement the original post's reposts count
+ from(t in Post, where: t.id == ^post_id)
+ |> Repo.update_all(inc: [reposts_count: -1])
+
+ # Decrement user's posts count
+ from(u in User, where: u.id == ^user_id)
+ |> Repo.update_all(inc: [posts_count: -1])
+
+ {:ok, repost}
+ end
+ end
+
+ @doc """
+ Updates a post.
+
+ ## Examples
+
+ iex> update_post(post, %{field: new_value})
+ {:ok, %Post{}}
+
+ iex> update_post(post, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_post(%Post{} = post, attrs) do
+ post
+ |> Post.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a post.
+
+ ## Examples
+
+ iex> delete_post(post)
+ {:ok, %Post{}}
+
+ iex> delete_post(post)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_post(%Post{} = post) do
+ result = Repo.delete(post)
+
+ case result do
+ {:ok, deleted_post} ->
+ # Decrement user's posts count
+ from(u in User, where: u.id == ^deleted_post.user_id)
+ |> Repo.update_all(inc: [posts_count: -1])
+
+ {:ok, deleted_post}
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking post changes.
+
+ ## Examples
+
+ iex> change_post(post)
+ %Ecto.Changeset{data: %Post{}}
+
+ """
+ def change_post(%Post{} = post, attrs \\ %{}) do
+ Post.changeset(post, attrs)
+ end
+
+ ## Likes
+
+ @doc """
+ Creates a like.
+ """
+ def create_like(attrs \\ %{}) do
+ result =
+ %Like{}
+ |> Like.changeset(attrs)
+ |> Repo.insert()
+
+ case result do
+ {:ok, like} ->
+ # Increment post's likes count
+ from(t in Post, where: t.id == ^like.post_id)
+ |> Repo.update_all(inc: [likes_count: 1])
+
+ # Broadcast the like
+ Phoenix.PubSub.broadcast(
+ Malarkey.PubSub,
+ "post:#{like.post_id}",
+ {:like_added, like}
+ )
+
+ {:ok, like}
+
+ {:error, changeset} ->
+ # If it's a unique constraint error, it means already liked
+ if changeset.errors[:user_id] || changeset.errors[:post_id] do
+ {:error, :already_liked}
+ else
+ {:error, changeset}
+ end
+ end
+ end
+
+ @doc """
+ Deletes a like.
+ """
+ def delete_like(user_id, post_id) do
+ from(l in Like,
+ where: l.user_id == ^user_id and l.post_id == ^post_id
+ )
+ |> Repo.one()
+ |> case do
+ nil ->
+ {:error, :not_found}
+
+ like ->
+ Repo.delete(like)
+
+ # Decrement post's likes count
+ from(t in Post, where: t.id == ^post_id)
+ |> Repo.update_all(inc: [likes_count: -1])
+
+ # Broadcast the unlike
+ Phoenix.PubSub.broadcast(
+ Malarkey.PubSub,
+ "post:#{post_id}",
+ {:like_removed, like}
+ )
+
+ {:ok, like}
+ end
+ end
+
+ @doc """
+ Checks if a user has liked a post.
+ """
+ def liked_by_user?(user_id, post_id) do
+ Repo.exists?(
+ from l in Like,
+ where: l.user_id == ^user_id and l.post_id == ^post_id
+ )
+ end
+
+ ## Follows
+
+ @doc """
+ Creates a follow relationship.
+ """
+ def create_follow(attrs \\ %{}) do
+ result =
+ %Follow{}
+ |> Follow.changeset(attrs)
+ |> Repo.insert()
+
+ case result do
+ {:ok, follow} ->
+ # Increment follower's following count
+ from(u in User, where: u.id == ^follow.follower_id)
+ |> Repo.update_all(inc: [following_count: 1])
+
+ # Increment following's followers count
+ from(u in User, where: u.id == ^follow.following_id)
+ |> Repo.update_all(inc: [followers_count: 1])
+
+ # Broadcast the follow
+ Phoenix.PubSub.broadcast(
+ Malarkey.PubSub,
+ "user:#{follow.following_id}",
+ {:new_follower, follow}
+ )
+
+ {:ok, follow}
+
+ {:error, changeset} ->
+ # If it's a unique constraint error, it means already following
+ if changeset.errors[:follower_id] || changeset.errors[:following_id] do
+ {:error, :already_following}
+ else
+ {:error, changeset}
+ end
+ end
+ end
+
+ @doc """
+ Deletes a follow relationship.
+ """
+ def delete_follow(follower_id, following_id) do
+ from(f in Follow,
+ where: f.follower_id == ^follower_id and f.following_id == ^following_id
+ )
+ |> Repo.one()
+ |> case do
+ nil ->
+ {:error, :not_found}
+
+ follow ->
+ Repo.delete(follow)
+
+ # Decrement follower's following count
+ from(u in User, where: u.id == ^follower_id)
+ |> Repo.update_all(inc: [following_count: -1])
+
+ # Decrement following's followers count
+ from(u in User, where: u.id == ^following_id)
+ |> Repo.update_all(inc: [followers_count: -1])
+
+ {:ok, follow}
+ end
+ end
+
+ @doc """
+ Checks if a user is following another user.
+ """
+ def following?(follower_id, following_id) do
+ Repo.exists?(
+ from f in Follow,
+ where: f.follower_id == ^follower_id and f.following_id == ^following_id
+ )
+ end
+
+ @doc """
+ Gets a list of followers for a user.
+ """
+ def list_followers(user, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+
+ from(u in User,
+ join: f in Follow,
+ on: f.follower_id == u.id,
+ where: f.following_id == ^user.id,
+ order_by: [desc: f.inserted_at],
+ limit: ^limit
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets a list of users that a user is following.
+ """
+ def list_following(user, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+
+ from(u in User,
+ join: f in Follow,
+ on: f.following_id == u.id,
+ where: f.follower_id == ^user.id,
+ order_by: [desc: f.inserted_at],
+ limit: ^limit
+ )
+ |> Repo.all()
+ end
+end
diff --git a/lib/malarkey/social/follow.ex b/lib/malarkey/social/follow.ex
new file mode 100644
index 0000000..1bdca49
--- /dev/null
+++ b/lib/malarkey/social/follow.ex
@@ -0,0 +1,24 @@
+defmodule Malarkey.Social.Follow do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "follows" do
+ belongs_to :follower, Malarkey.Accounts.User
+ belongs_to :following, Malarkey.Accounts.User
+
+ timestamps(updated_at: false)
+ end
+
+ @doc false
+ def changeset(follow, attrs) do
+ follow
+ |> cast(attrs, [:follower_id, :following_id])
+ |> validate_required([:follower_id, :following_id])
+ |> unique_constraint([:follower_id, :following_id])
+ |> check_constraint(:follower_id, name: :cannot_follow_self, message: "cannot follow yourself")
+ |> foreign_key_constraint(:follower_id)
+ |> foreign_key_constraint(:following_id)
+ end
+end
diff --git a/lib/malarkey/social/like.ex b/lib/malarkey/social/like.ex
new file mode 100644
index 0000000..e633401
--- /dev/null
+++ b/lib/malarkey/social/like.ex
@@ -0,0 +1,23 @@
+defmodule Malarkey.Social.Like do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "likes" do
+ belongs_to :user, Malarkey.Accounts.User
+ belongs_to :post, Malarkey.Social.Post
+
+ timestamps(updated_at: false)
+ end
+
+ @doc false
+ def changeset(like, attrs) do
+ like
+ |> cast(attrs, [:user_id, :post_id])
+ |> validate_required([:user_id, :post_id])
+ |> unique_constraint([:user_id, :post_id])
+ |> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:post_id)
+ end
+end
diff --git a/lib/malarkey/social/post.ex b/lib/malarkey/social/post.ex
new file mode 100644
index 0000000..4af78c2
--- /dev/null
+++ b/lib/malarkey/social/post.ex
@@ -0,0 +1,93 @@
+defmodule Malarkey.Social.Post do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "posts" do
+ field :body, :string, source: :content
+ field :media_urls, {:array, :string}, default: []
+ field :media_types, {:array, :string}, default: []
+ field :likes_count, :integer, default: 0
+ field :reposts_count, :integer, default: 0
+ field :replies_count, :integer, default: 0
+ field :views_count, :integer, default: 0
+
+ belongs_to :user, Malarkey.Accounts.User
+ belongs_to :reply_to, Malarkey.Social.Post
+ belongs_to :repost_of, Malarkey.Social.Post
+ belongs_to :quote_post, Malarkey.Social.Post
+
+ has_many :likes, Malarkey.Social.Like
+ has_many :replies, Malarkey.Social.Post, foreign_key: :reply_to_id
+ has_many :reposts, Malarkey.Social.Post, foreign_key: :repost_of_id
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(post, attrs) do
+ # Normalize attrs to support both body and content keys
+ attrs = normalize_attrs(attrs)
+
+ post
+ |> cast(attrs, [:body, :media_urls, :media_types, :user_id, :reply_to_id, :repost_of_id, :quote_post_id], empty_values: [])
+ |> ensure_body_for_media()
+ |> validate_required([:user_id])
+ |> validate_body_or_media()
+ |> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:reply_to_id)
+ |> foreign_key_constraint(:repost_of_id)
+ |> foreign_key_constraint(:quote_post_id)
+ end
+
+ @doc false
+ def repost_changeset(post, attrs) do
+ post
+ |> cast(attrs, [:user_id, :repost_of_id])
+ |> validate_required([:user_id, :repost_of_id])
+ |> put_change(:body, " ")
+ |> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:repost_of_id)
+ end
+
+ # Private functions
+
+ defp normalize_attrs(attrs) when is_map(attrs) do
+ # Support both body and content keys for migration
+ cond do
+ Map.has_key?(attrs, :body) or Map.has_key?(attrs, "body") -> attrs
+ Map.has_key?(attrs, :content) -> Map.put(attrs, :body, attrs[:content])
+ Map.has_key?(attrs, "content") -> Map.put(attrs, :body, attrs["content"])
+ true -> attrs
+ end
+ end
+
+ defp ensure_body_for_media(changeset) do
+ # After cast, if body is nil/empty and media is present, set body to a space
+ body = get_field(changeset, :body)
+ media_urls = get_field(changeset, :media_urls) || []
+
+ trimmed_body = if is_binary(body), do: String.trim(body), else: ""
+
+ if trimmed_body == "" and length(media_urls) > 0 do
+ put_change(changeset, :body, " ")
+ else
+ changeset
+ end
+ end
+
+ defp validate_body_or_media(changeset) do
+ body = get_field(changeset, :body)
+ media_urls = get_field(changeset, :media_urls) || []
+
+ trimmed_body = if is_binary(body), do: String.trim(body), else: ""
+
+ if trimmed_body == "" && Enum.empty?(media_urls) do
+ add_error(changeset, :body, "Post must have text or media")
+ else
+ changeset
+ |> validate_length(:body, max: 280, message: "Post is too long (maximum is 280 characters)")
+ end
+ end
+end
diff --git a/lib/malarkey/timeline.ex b/lib/malarkey/timeline.ex
deleted file mode 100644
index 8821364..0000000
--- a/lib/malarkey/timeline.ex
+++ /dev/null
@@ -1,87 +0,0 @@
-defmodule Malarkey.Timeline do
- @moduledoc """
- The Timeline context.
- """
-
- import Ecto.Query, warn: false
-
- alias Malarkey.Timeline.PostUserLike
- alias Malarkey.Repo
- alias Malarkey.Timeline.Post
-
- def list_posts() do
- # preloads = Keyword.get(opts, :preloads, [])
-
- Post
- |> order_by(desc: :inserted_at)
- |> Repo.all()
- |> Repo.preload(:user)
- |> Repo.preload(:liked_by)
- |> Repo.preload(:disliked_by)
- |> Repo.preload(:reposted_by)
- end
-
- def get_post!(id) do
- Repo.get!(Post, id)
- |> Repo.preload(:user)
- |> Repo.preload(:liked_by)
- |> Repo.preload(:disliked_by)
- |> Repo.preload(:reposted_by)
- end
-
- def create_post(user, attrs \\ %{}) do
- %Post{user_id: user.id}
- |> Post.changeset(attrs)
- |> Repo.insert()
- |> broadcast(:post_created)
- end
-
- def update_post(%Post{} = post, attrs \\ %{}) do
- post
- |> Post.changeset(attrs)
- |> Repo.update()
- |> broadcast(:post_updated)
- end
-
- def delete_post(%Post{} = post) do
- Repo.delete(post)
- end
-
- def change_post(%Post{} = post, attrs \\ %{}) do
- Post.changeset(post, attrs)
- end
-
- def add_like(user, post) do
- if user in post.liked_by do
- Repo.delete_all(PostUserLike.user_post_like_query(user, post))
- |> broadcast(:post_updated)
- else
- post
- |> Repo.preload(:liked_by)
- |> Repo.preload(:user)
- |> Post.changeset_add_like(user)
- |> Repo.update()
- |> broadcast(:post_updated)
- end
- end
-
- def add_repost(user, post) do
- post
- |> Repo.preload(:reposted_by)
- |> Repo.preload(:user)
- |> Post.changeset_add_repost(user)
- |> Repo.update()
- |> broadcast(:post_created)
- end
-
- def subscribe do
- Phoenix.PubSub.subscribe(Malarkey.PubSub, "posts")
- end
-
- defp broadcast({:error, _reason} = error, _event), do: error
-
- defp broadcast({:ok, post}, event) do
- Phoenix.PubSub.broadcast!(Malarkey.PubSub, "posts", {event, post})
- {:ok, post}
- end
-end
diff --git a/lib/malarkey/timeline/post.ex b/lib/malarkey/timeline/post.ex
deleted file mode 100644
index f4bfc33..0000000
--- a/lib/malarkey/timeline/post.ex
+++ /dev/null
@@ -1,63 +0,0 @@
-defmodule Malarkey.Timeline.Post do
- use Ecto.Schema
- import Ecto.Changeset
- alias Malarkey.Accounts.User
-
- schema "posts" do
- field :body, :string
- belongs_to(:user, User)
-
- many_to_many(
- :liked_by,
- User,
- join_through: Malarkey.Timeline.PostUserLike,
- on_replace: :delete
- )
-
- many_to_many(
- :disliked_by,
- User,
- join_through: Malarkey.Timeline.PostUserDislike,
- on_replace: :delete
- )
-
- many_to_many(
- :reposted_by,
- User,
- join_through: Malarkey.Timeline.PostUserRepost,
- on_replace: :delete
- )
-
- timestamps()
- end
-
- @spec changeset(
- {map, map}
- | %{
- :__struct__ => atom | %{:__changeset__ => map, optional(any) => any},
- optional(atom) => any
- },
- :invalid | %{optional(:__struct__) => none, optional(atom | binary) => any}
- ) :: Ecto.Changeset.t()
- @doc false
- def changeset(post, attrs) do
- post
- |> cast(attrs, [:body, :user_id])
- |> cast_assoc(:liked_by, required: false)
- |> validate_required([:body])
- |> validate_required([:user_id])
- |> validate_length(:body, min: 2, max: 250)
- end
-
- def changeset_add_like(post, user, attrs \\ %{}) do
- post
- |> changeset(attrs)
- |> put_assoc(:liked_by, [user])
- end
-
- def changeset_add_repost(post, user, attrs \\ %{}) do
- post
- |> changeset(attrs)
- |> put_assoc(:reposted_by, [user])
- end
-end
diff --git a/lib/malarkey/timeline/post_user_dislike.ex b/lib/malarkey/timeline/post_user_dislike.ex
deleted file mode 100644
index 03e7d6f..0000000
--- a/lib/malarkey/timeline/post_user_dislike.ex
+++ /dev/null
@@ -1,10 +0,0 @@
-defmodule Malarkey.Timeline.PostUserDislike do
- use Ecto.Schema
-
- @primary_key false
- schema "user_dislikes" do
- belongs_to :user, Malarkey.Accounts.User, primary_key: true
- belongs_to :post, Malarkey.Timeline.Post, primary_key: true
- timestamps()
- end
-end
diff --git a/lib/malarkey/timeline/post_user_like.ex b/lib/malarkey/timeline/post_user_like.ex
deleted file mode 100644
index 2a58747..0000000
--- a/lib/malarkey/timeline/post_user_like.ex
+++ /dev/null
@@ -1,16 +0,0 @@
-defmodule Malarkey.Timeline.PostUserLike do
- import Ecto.Query
- use Ecto.Schema
- alias Malarkey.Timeline.PostUserLike
-
- @primary_key false
- schema "user_likes" do
- belongs_to :user, Malarkey.Accounts.User, primary_key: true
- belongs_to :post, Malarkey.Timeline.Post, primary_key: true
- timestamps()
- end
-
- def user_post_like_query(user, post) do
- from PostUserLike, where: [user_id: ^user.id, post_id: ^post.id]
- end
-end
diff --git a/lib/malarkey/timeline/post_user_repost.ex b/lib/malarkey/timeline/post_user_repost.ex
deleted file mode 100644
index d88bc90..0000000
--- a/lib/malarkey/timeline/post_user_repost.ex
+++ /dev/null
@@ -1,10 +0,0 @@
-defmodule Malarkey.Timeline.PostUserRepost do
- use Ecto.Schema
-
- @primary_key false
- schema "user_reposts" do
- belongs_to :user, Malarkey.Accounts.User, primary_key: true
- belongs_to :post, Malarkey.Timeline.Post, primary_key: true
- timestamps()
- end
-end
diff --git a/lib/malarkey_web.ex b/lib/malarkey_web.ex
index 28dcb57..99b2740 100644
--- a/lib/malarkey_web.ex
+++ b/lib/malarkey_web.ex
@@ -39,12 +39,12 @@ defmodule MalarkeyWeb do
def controller do
quote do
use Phoenix.Controller,
- namespace: MalarkeyWeb,
formats: [:html, :json],
layouts: [html: MalarkeyWeb.Layouts]
+ use Gettext, backend: MalarkeyWeb.Gettext
+
import Plug.Conn
- import MalarkeyWeb.Gettext
unquote(verified_routes())
end
@@ -67,9 +67,6 @@ defmodule MalarkeyWeb do
end
end
- @spec html ::
- {:__block__, [],
- [{:__block__, [], [...]} | {:import, [...], [...]} | {:use, [...], [...]}, ...]}
def html do
quote do
use Phoenix.Component
@@ -85,11 +82,14 @@ defmodule MalarkeyWeb do
defp html_helpers do
quote do
+ # Translation
+ use Gettext, backend: MalarkeyWeb.Gettext
+
# HTML escaping functionality
import Phoenix.HTML
- # Core UI components and translation
+ # Core UI components
import MalarkeyWeb.CoreComponents
- import MalarkeyWeb.Gettext
+ import MalarkeyWeb.Components.Avatar
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
@@ -109,7 +109,7 @@ defmodule MalarkeyWeb do
end
@doc """
- When used, dispatch to the appropriate controller/view/etc.
+ When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
diff --git a/lib/malarkey_web/components/avatar.ex b/lib/malarkey_web/components/avatar.ex
new file mode 100644
index 0000000..7ed4fcb
--- /dev/null
+++ b/lib/malarkey_web/components/avatar.ex
@@ -0,0 +1,53 @@
+defmodule MalarkeyWeb.Components.Avatar do
+ use Phoenix.Component
+
+ @doc """
+ Renders a user avatar with fallback to initials.
+
+ ## Examples
+
+ <.avatar user={@user} size="sm" />
+ <.avatar user={@user} size="md" />
+ <.avatar user={@user} size="lg" />
+ <.avatar user={@user} size="xl" />
+ """
+ attr :user, :map, required: true, doc: "The user struct containing avatar_url and username"
+ attr :size, :string, default: "md", values: ["sm", "md", "lg", "xl"]
+ attr :class, :string, default: "", doc: "Additional CSS classes"
+
+ def avatar(assigns) do
+ ~H"""
+ <%= if @user.avatar_url do %>
+
+ <% else %>
+
+ <%= initial(@user) %>
+
+ <% end %>
+ """
+ end
+
+ defp size_class("sm"), do: "w-8 h-8 text-xs"
+ defp size_class("md"), do: "w-10 h-10 text-sm"
+ defp size_class("lg"), do: "w-12 h-12 text-base"
+ defp size_class("xl"), do: "w-16 h-16 text-xl"
+
+ defp initial(user) do
+ (user.display_name || user.username)
+ |> String.first()
+ |> String.upcase()
+ end
+end
diff --git a/lib/malarkey_web/components/core_components.ex b/lib/malarkey_web/components/core_components.ex
index b283f0f..fd9e1c1 100644
--- a/lib/malarkey_web/components/core_components.ex
+++ b/lib/malarkey_web/components/core_components.ex
@@ -2,17 +2,22 @@ defmodule MalarkeyWeb.CoreComponents do
@moduledoc """
Provides core UI components.
- The components in this module use Tailwind CSS, a utility-first CSS framework.
- See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to
- customize the generated components in this module.
+ At first glance, this module may seem daunting, but its goal is to provide
+ core building blocks for your application, such as modals, tables, and
+ forms. The components consist mostly of markup and are well-documented
+ with doc strings and declarative assigns. You may customize and style
+ them in any way you want, based on your application growth and needs.
- Icons are provided by [heroicons](https://heroicons.com), using the
- [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project.
+ The default components use Tailwind CSS, a utility-first CSS framework.
+ See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
+ how to customize them or feel free to swap in another framework altogether.
+
+ Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
"""
use Phoenix.Component
+ use Gettext, backend: MalarkeyWeb.Gettext
alias Phoenix.LiveView.JS
- import MalarkeyWeb.Gettext
@doc """
Renders a modal.
@@ -20,35 +25,32 @@ defmodule MalarkeyWeb.CoreComponents do
## Examples
<.modal id="confirm-modal">
- Are you sure?
- <:confirm>OK
- <:cancel>Cancel
+ This is a modal.
- JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
- for the caller to react to each button press, for example:
+ JS commands may be passed to the `:on_cancel` to configure
+ the closing/cancel event, for example:
- <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
- Are you sure you?
- <:confirm>OK
- <:cancel>Cancel
+ <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
+ This is another modal.
+
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
- attr :on_confirm, JS, default: %JS{}
-
slot :inner_block, required: true
- slot :title
- slot :subtitle
- slot :confirm
- slot :cancel
def modal(assigns) do
~H"""
-
@@ -122,74 +97,103 @@ defmodule MalarkeyWeb.CoreComponents do
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
"""
- attr :id, :string, default: "flash", doc: "the optional id of flash container"
+ attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
- attr :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount"
- attr :close, :boolean, default: true, doc: "whether the flash can be closed"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
+ assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
+
~H"""
"""
end
+ @doc """
+ Shows the flash group with standard titles and content.
+
+ ## Examples
+
+ <.flash_group flash={@flash} />
+ """
+ attr :flash, :map, required: true, doc: "the map of flash messages"
+ attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
+
+ def flash_group(assigns) do
+ ~H"""
+
+ <.flash kind={:info} title={gettext("Success!")} flash={@flash} />
+ <.flash kind={:error} title={gettext("Error!")} flash={@flash} />
+ <.flash
+ id="client-error"
+ kind={:error}
+ title={gettext("We can't find the internet")}
+ phx-disconnected={show(".phx-client-error #client-error")}
+ phx-connected={hide("#client-error")}
+ hidden
+ >
+ {gettext("Attempting to reconnect")}
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+
+
+ <.flash
+ id="server-error"
+ kind={:error}
+ title={gettext("Something went wrong!")}
+ phx-disconnected={show(".phx-server-error #server-error")}
+ phx-connected={hide("#server-error")}
+ hidden
+ >
+ {gettext("Hang in there while we get back on track")}
+ <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+
+
+ """
+ end
+
@doc """
Renders a simple form.
## Examples
- <.simple_form :let={f} for={:user} phx-change="validate" phx-submit="save">
- <.input field={{f, :email}} label="Email"/>
- <.input field={{f, :username}} label="Username" />
+ <.simple_form for={@form} phx-change="validate" phx-submit="save">
+ <.input field={@form[:email]} label="Email"/>
+ <.input field={@form[:username]} label="Username" />
<:actions>
<.button>Save
"""
- attr :for, :any, default: nil, doc: "the datastructure for the form"
+ attr :for, :any, required: true, doc: "the data structure for the form"
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
attr :rest, :global,
- include: ~w(autocomplete name rel action enctype method novalidate target),
+ include: ~w(autocomplete name rel action enctype method novalidate target multipart),
doc: "the arbitrary HTML attributes to apply to the form tag"
slot :inner_block, required: true
@@ -199,9 +203,9 @@ defmodule MalarkeyWeb.CoreComponents do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
@@ -233,7 +237,7 @@ defmodule MalarkeyWeb.CoreComponents do
]}
{@rest}
>
- <%= render_slot(@inner_block) %>
+ {render_slot(@inner_block)}
"""
end
@@ -241,137 +245,149 @@ defmodule MalarkeyWeb.CoreComponents do
@doc """
Renders an input with label and error messages.
- A `%Phoenix.HTML.Form{}` and field name may be passed to the input
- to build input names and error messages, or all the attributes and
- errors may be passed explicitly.
+ A `Phoenix.HTML.FormField` may be passed as argument,
+ which is used to retrieve the input name, id, and values.
+ Otherwise all attributes may be passed explicitly.
+
+ ## Types
+
+ This function accepts all HTML input types, considering that:
+
+ * You may also set `type="select"` to render a `