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 %> + {@user.username} + <% 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""" -