diff --git a/Pipfile b/Pipfile
index 4d377014ae..f75fb3d98a 100644
--- a/Pipfile
+++ b/Pipfile
@@ -20,6 +20,7 @@ typing-extensions = "*"
flask-jwt-extended = "==4.6.0"
wtforms = "==3.1.2"
sqlalchemy = "*"
+flask-bcrypt = "*"
[requires]
python_version = "3.13"
diff --git a/Pipfile.lock b/Pipfile.lock
index d9e474e972..38336a5b2a 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "ffbfb32d0afa5e4bcaba5c2d08c81381a97abd90f22284d2b76647365df5dc50"
+ "sha256": "9c612f7eebcf717533779dc02b40fce5eeaf66bfab71c81d166828128c272240"
},
"pipfile-spec": 6,
"requires": {
@@ -24,6 +24,75 @@
"markers": "python_version >= '3.10'",
"version": "==1.17.1"
},
+ "bcrypt": {
+ "hashes": [
+ "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4",
+ "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a",
+ "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464",
+ "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4",
+ "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746",
+ "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2",
+ "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41",
+ "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd",
+ "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9",
+ "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e",
+ "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538",
+ "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10",
+ "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb",
+ "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef",
+ "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4",
+ "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23",
+ "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef",
+ "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75",
+ "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42",
+ "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a",
+ "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172",
+ "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683",
+ "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2",
+ "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4",
+ "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba",
+ "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da",
+ "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493",
+ "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254",
+ "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534",
+ "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f",
+ "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c",
+ "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c",
+ "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83",
+ "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff",
+ "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d",
+ "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861",
+ "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5",
+ "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9",
+ "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b",
+ "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac",
+ "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e",
+ "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f",
+ "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb",
+ "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86",
+ "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980",
+ "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd",
+ "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d",
+ "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1",
+ "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911",
+ "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993",
+ "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191",
+ "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4",
+ "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2",
+ "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8",
+ "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db",
+ "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927",
+ "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be",
+ "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb",
+ "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e",
+ "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf",
+ "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd",
+ "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822",
+ "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"
+ ],
+ "markers": "python_version >= '3.8'",
+ "version": "==5.0.0"
+ },
"blinker": {
"hashes": [
"sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf",
@@ -42,11 +111,11 @@
},
"click": {
"hashes": [
- "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc",
- "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"
+ "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2",
+ "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"
],
"markers": "python_version >= '3.10'",
- "version": "==8.3.0"
+ "version": "==8.4.1"
},
"cloudinary": {
"hashes": [
@@ -58,12 +127,12 @@
},
"flask": {
"hashes": [
- "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87",
- "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"
+ "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb",
+ "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==3.1.2"
+ "version": "==3.1.3"
},
"flask-admin": {
"hashes": [
@@ -74,6 +143,14 @@
"markers": "python_version >= '3.10'",
"version": "==2.0.0"
},
+ "flask-bcrypt": {
+ "hashes": [
+ "sha256:062fd991dc9118d05ac0583675507b9fe4670e44416c97e0e6819d03d01f808a",
+ "sha256:f07b66b811417ea64eb188ae6455b0b708a793d966e1a80ceec4a23bc42a4369"
+ ],
+ "index": "pypi",
+ "version": "==1.0.1"
+ },
"flask-cors": {
"hashes": [
"sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c",
@@ -575,11 +652,11 @@
},
"werkzeug": {
"hashes": [
- "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e",
- "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"
+ "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50",
+ "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44"
],
"markers": "python_version >= '3.9'",
- "version": "==3.1.3"
+ "version": "==3.1.8"
},
"wtforms": {
"hashes": [
diff --git a/index.html b/index.html
index 27a99f796e..053231b6da 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,7 @@
+
Hello Rigo
diff --git a/migrations/versions/0763d677d453_.py b/migrations/versions/0763d677d453_.py
deleted file mode 100644
index 88964176f1..0000000000
--- a/migrations/versions/0763d677d453_.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""empty message
-
-Revision ID: 0763d677d453
-Revises:
-Create Date: 2025-02-25 14:47:16.337069
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '0763d677d453'
-down_revision = None
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- # ### commands auto generated by Alembic - please adjust! ###
- op.create_table('user',
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('email', sa.String(length=120), nullable=False),
- sa.Column('password', sa.String(), nullable=False),
- sa.Column('is_active', sa.Boolean(), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('email')
- )
- # ### end Alembic commands ###
-
-
-def downgrade():
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('user')
- # ### end Alembic commands ###
diff --git a/migrations/versions/3b9cabc9e89c_.py b/migrations/versions/3b9cabc9e89c_.py
new file mode 100644
index 0000000000..377d0464e6
--- /dev/null
+++ b/migrations/versions/3b9cabc9e89c_.py
@@ -0,0 +1,42 @@
+"""empty message
+
+Revision ID: 3b9cabc9e89c
+Revises: 565ffd5984d2
+Create Date: 2026-06-19 20:17:46.106958
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '3b9cabc9e89c'
+down_revision = '565ffd5984d2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('user', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False))
+ batch_op.add_column(sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False))
+ batch_op.alter_column('img',
+ existing_type=sa.VARCHAR(length=255),
+ type_=sa.Text(),
+ existing_nullable=True)
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('user', schema=None) as batch_op:
+ batch_op.alter_column('img',
+ existing_type=sa.Text(),
+ type_=sa.VARCHAR(length=255),
+ existing_nullable=True)
+ batch_op.drop_column('updated_at')
+ batch_op.drop_column('created_at')
+
+ # ### end Alembic commands ###
diff --git a/migrations/versions/b25cdff97e19_.py b/migrations/versions/b25cdff97e19_.py
new file mode 100644
index 0000000000..5929b844bb
--- /dev/null
+++ b/migrations/versions/b25cdff97e19_.py
@@ -0,0 +1,83 @@
+"""empty message
+
+Revision ID: b25cdff97e19
+Revises:
+Create Date: 2026-06-17 19:42:08.904130
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'b25cdff97e19'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('tag_select',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('foro_id', sa.Integer(), nullable=True),
+ sa.Column('tag_id', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['foro_id'], ['foro.id'], ),
+ sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ with op.batch_alter_table('foro', schema=None) as batch_op:
+ batch_op.alter_column('img',
+ existing_type=sa.VARCHAR(length=255),
+ type_=sa.Text(),
+ existing_nullable=True)
+ batch_op.alter_column('description',
+ existing_type=sa.VARCHAR(length=255),
+ type_=sa.Text(),
+ existing_nullable=True)
+
+ with op.batch_alter_table('post', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('title', sa.String(length=120), nullable=False))
+ batch_op.alter_column('img',
+ existing_type=sa.VARCHAR(length=255),
+ type_=sa.Text(),
+ existing_nullable=True)
+
+ with op.batch_alter_table('tag', schema=None) as batch_op:
+ batch_op.drop_constraint(batch_op.f('tag_foro_id_fkey'), type_='foreignkey')
+ batch_op.drop_constraint(batch_op.f('tag_user_id_fkey'), type_='foreignkey')
+ batch_op.drop_column('foro_id')
+ batch_op.drop_column('user_id')
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('tag', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False))
+ batch_op.add_column(sa.Column('foro_id', sa.INTEGER(), autoincrement=False, nullable=False))
+ batch_op.create_foreign_key(batch_op.f('tag_user_id_fkey'), 'user', ['user_id'], ['id'])
+ batch_op.create_foreign_key(batch_op.f('tag_foro_id_fkey'), 'foro', ['foro_id'], ['id'])
+
+ with op.batch_alter_table('post', schema=None) as batch_op:
+ batch_op.alter_column('img',
+ existing_type=sa.Text(),
+ type_=sa.VARCHAR(length=255),
+ existing_nullable=True)
+ batch_op.drop_column('title')
+
+ with op.batch_alter_table('foro', schema=None) as batch_op:
+ batch_op.alter_column('description',
+ existing_type=sa.Text(),
+ type_=sa.VARCHAR(length=255),
+ existing_nullable=True)
+ batch_op.alter_column('img',
+ existing_type=sa.Text(),
+ type_=sa.VARCHAR(length=255),
+ existing_nullable=True)
+
+ op.drop_table('tag_select')
+ # ### end Alembic commands ###
diff --git a/package-lock.json b/package-lock.json
index 8d43d98ab7..96b93b961d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-icons": "^5.6.0",
"react-router-dom": "^6.18.0"
},
"devDependencies": {
@@ -877,19 +878,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/@jridgewell/source-map": {
- "version": "0.3.6",
- "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
- "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25"
- }
- },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -997,14 +985,6 @@
"@babel/types": "^7.20.7"
}
},
- "node_modules/@types/node": {
- "version": "16.11.12",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz",
- "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@@ -1307,14 +1287,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -3506,6 +3478,15 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-icons": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
+ "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -3915,29 +3896,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- },
- "node_modules/source-map-support/node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -4074,35 +4032,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/terser": {
- "version": "5.38.1",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz",
- "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/source-map": "^0.3.3",
- "acorn": "^8.8.2",
- "commander": "^2.20.0",
- "source-map-support": "~0.5.20"
- },
- "bin": {
- "terser": "bin/terser"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/terser/node_modules/commander": {
- "version": "2.20.3",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -4944,18 +4873,6 @@
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true
},
- "@jridgewell/source-map": {
- "version": "0.3.6",
- "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
- "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
- "dev": true,
- "optional": true,
- "peer": true,
- "requires": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25"
- }
- },
"@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -5044,14 +4961,6 @@
"@babel/types": "^7.20.7"
}
},
- "@types/node": {
- "version": "16.11.12",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz",
- "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@@ -5252,14 +5161,6 @@
"update-browserslist-db": "^1.1.1"
}
},
- "buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6715,6 +6616,12 @@
"scheduler": "^0.23.2"
}
},
+ "react-icons": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
+ "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
+ "requires": {}
+ },
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -6982,28 +6889,6 @@
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true
},
- "source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
- "optional": true,
- "peer": true,
- "requires": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- },
- "dependencies": {
- "source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
- "optional": true,
- "peer": true
- }
- }
- },
"string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -7094,30 +6979,6 @@
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true
},
- "terser": {
- "version": "5.38.1",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz",
- "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
- "dev": true,
- "optional": true,
- "peer": true,
- "requires": {
- "@jridgewell/source-map": "^0.3.3",
- "acorn": "^8.8.2",
- "commander": "^2.20.0",
- "source-map-support": "~0.5.20"
- },
- "dependencies": {
- "commander": {
- "version": "2.20.3",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "dev": true,
- "optional": true,
- "peer": true
- }
- }
- },
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
diff --git a/package.json b/package.json
index 0caab10749..05b8fd5fb9 100755
--- a/package.json
+++ b/package.json
@@ -8,10 +8,10 @@
"main": "index.js",
"scripts": {
"dev": "vite",
- "start": "vite",
- "build": "vite build",
- "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
- "preview": "vite preview"
+ "start": "vite",
+ "build": "vite build",
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
},
"author": {
"name": "Alejandro Sanchez",
@@ -30,13 +30,13 @@
"license": "ISC",
"devDependencies": {
"@types/react": "^18.2.18",
- "@types/react-dom": "^18.2.7",
- "@vitejs/plugin-react": "^4.0.4",
- "eslint": "^8.46.0",
- "eslint-plugin-react": "^7.33.1",
- "eslint-plugin-react-hooks": "^4.6.0",
- "eslint-plugin-react-refresh": "^0.4.3",
- "vite": "^4.4.8"
+ "@types/react-dom": "^18.2.7",
+ "@vitejs/plugin-react": "^4.0.4",
+ "eslint": "^8.46.0",
+ "eslint-plugin-react": "^7.33.1",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.3",
+ "vite": "^4.4.8"
},
"babel": {
"presets": [
@@ -55,8 +55,9 @@
},
"dependencies": {
"prop-types": "^15.8.1",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "react-router-dom": "^6.18.0"
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-icons": "^5.6.0",
+ "react-router-dom": "^6.18.0"
}
}
diff --git a/src/api/admin.py b/src/api/admin.py
index d1bd1d25ba..e450adc950 100644
--- a/src/api/admin.py
+++ b/src/api/admin.py
@@ -1,8 +1,8 @@
import os
import inspect
from flask_admin import Admin
-from . import models
-from .models import db
+from api import models
+from api.database.db import db
from flask_admin.contrib.sqla import ModelView
from flask_admin.theme import Bootstrap4Theme
diff --git a/src/api/cloudinary/cloudinary_config.py b/src/api/cloudinary/cloudinary_config.py
new file mode 100644
index 0000000000..18230f12a3
--- /dev/null
+++ b/src/api/cloudinary/cloudinary_config.py
@@ -0,0 +1,14 @@
+import os
+import cloudinary
+
+print("CLOUD_NAME =", os.getenv("CLOUDINARY_CLOUD_NAME"))
+print("API_KEY =", os.getenv("CLOUDINARY_API_KEY"))
+print("API_SECRET =", os.getenv("CLOUDINARY_API_SECRET"))
+
+
+cloudinary.config(
+ cloud_name= os.getenv("CLOUDINARY_CLOUD_NAME"),
+ api_key= os.getenv("CLOUDINARY_API_KEY"),
+ api_secret= os.getenv("CLOUDINARY_API_SECRET"),
+ secure=True
+)
\ No newline at end of file
diff --git a/src/api/commands.py b/src/api/commands.py
index 19806164d3..d934329676 100644
--- a/src/api/commands.py
+++ b/src/api/commands.py
@@ -1,6 +1,6 @@
import click
-from api.models import db, User
+from api.models.models_user import db, User
"""
In this file, you can add as many commands as you want using the @app.cli.command decorator
diff --git a/src/api/database/db.py b/src/api/database/db.py
new file mode 100644
index 0000000000..f0b13d6f2a
--- /dev/null
+++ b/src/api/database/db.py
@@ -0,0 +1,3 @@
+from flask_sqlalchemy import SQLAlchemy
+
+db = SQLAlchemy()
diff --git a/src/api/extension.py b/src/api/extension.py
new file mode 100644
index 0000000000..4f7710b60b
--- /dev/null
+++ b/src/api/extension.py
@@ -0,0 +1,3 @@
+from flask_bcrypt import Bcrypt
+
+bcrypt = Bcrypt()
\ No newline at end of file
diff --git a/src/api/models.py b/src/api/models.py
deleted file mode 100644
index da515f6a1a..0000000000
--- a/src/api/models.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from flask_sqlalchemy import SQLAlchemy
-from sqlalchemy import String, Boolean
-from sqlalchemy.orm import Mapped, mapped_column
-
-db = SQLAlchemy()
-
-class User(db.Model):
- id: Mapped[int] = mapped_column(primary_key=True)
- email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
- password: Mapped[str] = mapped_column(nullable=False)
- is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False)
-
-
- def serialize(self):
- return {
- "id": self.id,
- "email": self.email,
- # do not serialize the password, its a security breach
- }
\ No newline at end of file
diff --git a/src/api/models/__init__.py b/src/api/models/__init__.py
new file mode 100644
index 0000000000..a69108f65a
--- /dev/null
+++ b/src/api/models/__init__.py
@@ -0,0 +1,7 @@
+from api.database.db import db
+from api.models.models_user import User
+from api.models.models_foro import Foro
+from api.models.model_post import Post
+from api.models.model_tag import Tag
+from api.models.model_select_tag import Tag_select
+
diff --git a/src/api/models/model_post.py b/src/api/models/model_post.py
new file mode 100644
index 0000000000..d8b0c4d97a
--- /dev/null
+++ b/src/api/models/model_post.py
@@ -0,0 +1,33 @@
+from api.database.db import db
+from sqlalchemy import String, Boolean, Text, ForeignKey, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from typing import List
+from datetime import datetime
+
+
+
+
+class Post (db.Model):
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(120), nullable=False)
+ content: Mapped[str] = mapped_column(String(255),nullable=False)
+ img: Mapped[str] = mapped_column(Text,nullable=True)
+ user_id = mapped_column(ForeignKey("user.id"), nullable=False)
+ foro_id: Mapped[int] = mapped_column(ForeignKey("foro.id"), nullable=True)
+ foro = relationship("Foro", back_populates="post")
+ created_at: Mapped[datetime] = mapped_column(server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
+
+
+ def serialize_post(self):
+ return {
+ "id": self.id,
+ "title": self.title,
+ "content": self.content,
+ "img": self.img,
+ "user_id": self.user_id,
+ "foro_id": self.foro_id,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None
+
+ }
\ No newline at end of file
diff --git a/src/api/models/model_select_tag.py b/src/api/models/model_select_tag.py
new file mode 100644
index 0000000000..0720a4443f
--- /dev/null
+++ b/src/api/models/model_select_tag.py
@@ -0,0 +1,30 @@
+from api.database.db import db
+from sqlalchemy import String, Boolean, ForeignKey, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from typing import List
+
+
+
+
+class Tag_select(db.Model):
+ id: Mapped[int] = mapped_column(primary_key=True)
+ user_id = mapped_column(ForeignKey("user.id"), nullable=True)
+ foro_id = mapped_column(ForeignKey("foro.id"), nullable=True)
+ tag_id = mapped_column(ForeignKey("tag.id"), nullable=False)
+ user = db.relationship('User', overlaps="tag")
+ foro = db.relationship('Foro', overlaps="tag")
+ tag = db.relationship('Tag', overlaps="tag")
+
+ def serialize_tag_user(self):
+ return{
+ "id": self.id,
+ "user_id": self.user_id,
+ "tag": self.tag.serialize_tag()
+ }
+
+ def serialize_tag_foro(self):
+ return{
+ "id": self.id,
+ "foro_id": self.foro_id,
+ "tag": self.tag.serialize_tag()
+ }
\ No newline at end of file
diff --git a/src/api/models/model_tag.py b/src/api/models/model_tag.py
new file mode 100644
index 0000000000..bde433fd9f
--- /dev/null
+++ b/src/api/models/model_tag.py
@@ -0,0 +1,17 @@
+from api.database.db import db
+from sqlalchemy import String, Boolean, ForeignKey, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from typing import List
+
+
+
+class Tag(db.Model):
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
+ tag = relationship('Tag_select')
+
+ def serialize_tag(self):
+ return{
+ "id": self.id,
+ "title": self.title,
+ }
\ No newline at end of file
diff --git a/src/api/models/models_foro.py b/src/api/models/models_foro.py
new file mode 100644
index 0000000000..cf3d4ebd8d
--- /dev/null
+++ b/src/api/models/models_foro.py
@@ -0,0 +1,31 @@
+from api.database.db import db
+from sqlalchemy import String, Boolean, Text, ForeignKey, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from typing import List
+from datetime import datetime
+
+
+
+class Foro(db.Model):
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(120), nullable=False)
+ img: Mapped[str] = mapped_column(Text, nullable=True)
+ description: Mapped[str] = mapped_column(Text, nullable=True)
+ user_id = mapped_column(ForeignKey("user.id"), nullable=False)
+ tag = relationship('Tag_select')
+ post = relationship('Post')
+ created_at: Mapped[datetime] = mapped_column(server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
+
+
+ def serialize_foro(self):
+ return {
+ "id": self.id,
+ "title": self.title,
+ "img": self.img,
+ "description": self.description,
+ "user_id": self.user_id,
+ "created_at": str(self.created_at),
+ "updated_at": str(self. updated_at)
+
+ }
\ No newline at end of file
diff --git a/src/api/models/models_user.py b/src/api/models/models_user.py
new file mode 100644
index 0000000000..cd4b5e1f98
--- /dev/null
+++ b/src/api/models/models_user.py
@@ -0,0 +1,46 @@
+from api.database.db import db
+from sqlalchemy import String, Boolean, ForeignKey, func, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from typing import List
+from datetime import datetime
+
+
+class User(db.Model):
+ id: Mapped[int] = mapped_column(primary_key=True)
+ username: Mapped[str]= mapped_column(String(120), unique=True, nullable=False)
+ email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
+ password: Mapped[str] = mapped_column(String(255),nullable=False)
+ img: Mapped[str] = mapped_column(Text, nullable=True)
+ first_name: Mapped[str] = mapped_column(String(255),nullable=True)
+ last_name: Mapped[str] = mapped_column(String(255),nullable=True)
+ date_birth: Mapped[str] = mapped_column(String(255),nullable=True)
+ description: Mapped[str] = mapped_column(String(255),nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean(), default=True)
+ foro = relationship('Foro')
+ post = relationship('Post')
+ tag = relationship('Tag_select')
+ created_at: Mapped[datetime] = mapped_column(server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
+
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "username": self.username,
+ "email": self.email,
+ "created_at": str(self.created_at),
+ "updated_at": str(self.updated_at)
+ }
+
+ def serialize_all(self):
+ return{
+ "id": self.id,
+ "username": self.username,
+ "email": self.email,
+ "img": self.img,
+ "first_name": self.first_name,
+ "last_name": self.last_name,
+ "date_birth": self.date_birth,
+ "description": self.description,
+ "updated_at": str(self.updated_at)
+ }
\ No newline at end of file
diff --git a/src/api/routes.py b/src/api/routes.py
deleted file mode 100644
index 029589a3a1..0000000000
--- a/src/api/routes.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""
-This module takes care of starting the API Server, Loading the DB and Adding the endpoints
-"""
-from flask import Flask, request, jsonify, url_for, Blueprint
-from api.models import db, User
-from api.utils import generate_sitemap, APIException
-from flask_cors import CORS
-
-api = Blueprint('api', __name__)
-
-# Allow CORS requests to this API
-CORS(api)
-
-
-@api.route('/hello', methods=['POST', 'GET'])
-def handle_hello():
-
- response_body = {
- "message": "Hello! I'm a message that came from the backend, check the network tab on the google inspector and you will see the GET request"
- }
-
- return jsonify(response_body), 200
diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py
new file mode 100644
index 0000000000..1050b5d813
--- /dev/null
+++ b/src/api/routes/__init__.py
@@ -0,0 +1,8 @@
+from flask import Blueprint
+from flask_cors import CORS
+
+api = Blueprint('api', __name__)
+CORS(api)
+
+# Las rutas se registran importando los módulos DESPUÉS de crear el blueprint
+from api.routes import routes_user, routes_tag, routes_foro, routes_post
\ No newline at end of file
diff --git a/src/api/routes/routes_foro.py b/src/api/routes/routes_foro.py
new file mode 100644
index 0000000000..964f6f1aa7
--- /dev/null
+++ b/src/api/routes/routes_foro.py
@@ -0,0 +1,90 @@
+from flask import request, jsonify
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from api.models.models_foro import Foro
+from api.database.db import db
+from . import api
+
+import cloudinary
+import cloudinary.uploader
+from api.cloudinary.cloudinary_config import *
+
+@api.route('/foro', methods=["POST"])
+@jwt_required()
+def create_forum():
+
+ title = request.form.get("title")
+ description = request.form.get("description")
+
+ if not title:
+ return jsonify({"msg": "Title is required"}), 400
+
+ img_url = None
+
+ if 'img' in request.files:
+ file_to_upload = request.files['img']
+ if file_to_upload.filename != '':
+ try:
+ upload_result = cloudinary.uploader.upload(file_to_upload)
+ img_url = upload_result["secure_url"]
+ except Exception as e:
+ return jsonify({"msg": str(e)}), 500
+
+ new_forum = Foro(
+ title=title,
+ img=img_url,
+ description=description,
+ user_id=int(get_jwt_identity())
+ )
+
+ db.session.add(new_forum)
+ db.session.commit()
+
+ return jsonify({
+ "msg": "Forum created",
+ "forum": new_forum.serialize_foro()
+ }), 201
+
+@api.route('/foros', methods=['GET'])
+def get_forums():
+
+ forums = Foro.query.all()
+
+ return jsonify([
+ forum.serialize_foro()
+ for forum in forums
+ ]), 200
+
+
+@api.route("/foro/", methods=["GET"])
+def get_forum_id(forum_id):
+
+ forum = Foro.query.get(forum_id)
+
+ if forum is None:
+ return jsonify({
+ "msg": "Forum not found"
+ }), 404
+
+ return jsonify(
+ forum.serialize_foro()
+ ), 200
+
+
+@api.route("/foros/search", methods=["GET"])
+def get_forum_search():
+
+ query = request.args.get("query")
+
+ if not query:
+ return jsonify({
+ "msg": "Query is required"
+ }), 400
+
+ forums = Foro.query.filter(
+ Foro.title.ilike(f"%{query}%")
+ ).all()
+
+ return jsonify([
+ forum.serialize_foro()
+ for forum in forums
+ ]), 200
\ No newline at end of file
diff --git a/src/api/routes/routes_post.py b/src/api/routes/routes_post.py
new file mode 100644
index 0000000000..febc0891af
--- /dev/null
+++ b/src/api/routes/routes_post.py
@@ -0,0 +1,116 @@
+from flask import request, jsonify
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from api.models.model_post import Post
+from api.database.db import db
+
+import cloudinary
+import cloudinary.uploader
+from api.cloudinary.cloudinary_config import *
+
+# Importación circular segura: se resuelve porque routes/__init__.py
+# ya terminó de crear `api` antes de llegar a esta línea
+from api.routes import api
+
+
+@api.route('/post', methods=['POST'])
+@jwt_required()
+def create_post():
+
+ content = request.form.get('content')
+ foro_id = request.form.get('foro_id')
+ title = request.form.get('title')
+
+ if not title:
+ return jsonify({
+ "msg": "Title is required"
+ }), 400
+
+ if not content:
+ return jsonify({
+ "msg": "Content is required"
+ }), 400
+
+ img_url = None
+
+ if 'img' in request.files:
+
+ file_to_upload = request.files['img']
+
+ if file_to_upload.filename != '':
+
+ try:
+
+ upload_result = cloudinary.uploader.upload(
+ file_to_upload,
+ upload_preset="neqycdyx"
+ )
+
+
+ img_url = upload_result["secure_url"]
+
+ except Exception as e:
+
+ return jsonify({
+ "msg": str(e)
+ }), 500
+
+ new_post = Post(
+ title=title,
+ content=content,
+ img=img_url,
+ foro_id=int(foro_id) if foro_id else None,
+ user_id=int(get_jwt_identity())
+ )
+
+ db.session.add(new_post)
+ db.session.commit()
+
+ return jsonify({
+ "msg": "Post created",
+ "post": new_post.serialize_post()
+ }), 201
+
+
+@api.route('/post/', methods=['GET'])
+def get_post_id(post_id):
+
+ post = Post.query.get(post_id)
+
+ if post is None:
+ return jsonify({
+ "msg": "Post not found"
+ }), 404
+
+ return jsonify(
+ post.serialize_post()
+ ), 200
+
+
+@api.route('/foro//posts', methods=['GET'])
+def get_posts_by_foro(foro_id):
+
+ posts = Post.query.filter_by(
+ foro_id=foro_id
+ ).order_by(
+ Post.created_at.desc()
+ ).all()
+
+ return jsonify([
+ post.serialize_post()
+ for post in posts
+ ]), 200
+
+
+@api.route('/foro//posts/top', methods=['GET'])
+def get_top_three_posts(foro_id):
+
+ posts = Post.query.filter_by(
+ foro_id=foro_id
+ ).order_by(
+ Post.created_at.desc()
+ ).limit(3).all()
+
+ return jsonify([
+ post.serialize_post()
+ for post in posts
+ ]), 200
diff --git a/src/api/routes/routes_tag.py b/src/api/routes/routes_tag.py
new file mode 100644
index 0000000000..0dd468c4fb
--- /dev/null
+++ b/src/api/routes/routes_tag.py
@@ -0,0 +1,115 @@
+from flask import request, jsonify
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from api.models.model_tag import Tag
+from api.models.models_user import User
+from api.models.models_foro import Foro
+from api.models.model_select_tag import Tag_select
+from api.database.db import db
+from . import api
+
+#para uso de creacion de una tag personalizada para que usuariO la cree
+# @api.route('/tag', methods=["POST"])
+# @jwt_required()
+# def create_tag():
+# data = request.get_json()
+
+# title = data.get("title")
+
+# if title is None:
+# return jsonify({"msg": "Title is required"}), 400
+
+# title_check = title.strip().lower()
+
+# tag_check = db.session.execute(db.select(Tag).where(Tag.title == title_check)).scalar_one_or_none()
+# if tag_check is not None:
+# return jsonify({"msg": "That tag is already registered"}), 400
+
+# new_tag = Tag(title=title_check, user_id = get_jwt_identity())
+# db.session.add(new_tag)
+# db.session.commit()
+
+# return jsonify({"msg": "Tag created", 'tag': new_tag.serialize_tag()}), 201
+
+@api.route('/tag', methods=["GET"])
+@jwt_required()
+def all_tags():
+ tag_list = db.session.query(Tag).all()
+ tag = list(map(lambda tag: tag.serialize_tag(), tag_list))
+
+ response_body = {"tag": tag}
+
+ return jsonify(response_body), 200
+
+@api.route('/tag-select', methods=["POST"])
+@jwt_required()
+def select_tag():
+ user_token = get_jwt_identity()
+
+ data = request.get_json()
+ tags_id = data.get('tags_id')
+ user_id = int(user_token)
+
+
+ if tags_id is None:
+ return jsonify({"msg": "Bad request i need tag_id"}), 400
+
+ for tag in tags_id:
+
+ tag_exists = db.session.get(Tag, tag)
+ if tag_exists is None:
+ return jsonify({"msg": "Tag not found"}), 404
+
+ new_tag_select = Tag_select(tag_id=tag, user_id=user_id)
+ db.session.add(new_tag_select)
+ db.session.commit()
+
+ return jsonify({"msg": "Tag assigned", "Tag_select":new_tag_select.serialize_tag_user()}), 201
+
+@api.route('/tag-select-foro', methods=["POST"])
+@jwt_required()
+def select_tagForo():
+ # user_token = get_jwt_identity()
+ # user_id = int(user_token)
+
+ data = request.get_json()
+ foro_id = data.get('foro_id')
+ tags_id = data.get('tags_id')
+
+ if tags_id is None:
+ return jsonify({"msg": "Bad request i need tag_id"}), 400
+
+ for tag in tags_id:
+
+ tag_exists = db.session.get(Tag, tag)
+ if tag_exists is None:
+ return jsonify({"msg": "Tag not found"}), 404
+
+ new_tag_select = Tag_select(tag_id=tag, foro_id=foro_id)
+ db.session.add(new_tag_select)
+ db.session.commit()
+
+ return jsonify({"msg": "Tag assigned", "Tag_select":new_tag_select.serialize_tag_foro()}), 201
+
+@api.route('/tag/user/', methods=['GET'])
+@jwt_required()
+def get_tag_from_user(user_id):
+ user = db.session.get(User, user_id)
+ if user is None:
+ return jsonify({"msg": "User not found"}), 400
+ query= db.select(Tag_select).where(Tag_select.user_id == user_id)
+ tag_select_list = db.session.execute(query).scalars().all()
+ tags = list(map(lambda Tag_select: Tag_select.serialize_tag_user(), tag_select_list))
+
+ return jsonify ({"user_id": user_id, "tags": tags})
+
+@api.route('/tag/foro/', methods=['GET'])
+@jwt_required()
+def get_tag_from_foro(foro_id):
+ foro = db.session.get(Foro, foro_id)
+ if foro is None:
+ return jsonify({"msg": "Foro not found"}), 400
+ query= db.select(Tag_select).where(Tag_select.foro_id == foro_id)
+ tag_select_list = db.session.execute(query).scalars().all()
+ tags = list(map(lambda Tag_select: Tag_select.serialize_tag_foro(), tag_select_list))
+
+ return jsonify ({"foro_id": foro_id, "tags": tags})
\ No newline at end of file
diff --git a/src/api/routes/routes_user.py b/src/api/routes/routes_user.py
new file mode 100644
index 0000000000..0185662563
--- /dev/null
+++ b/src/api/routes/routes_user.py
@@ -0,0 +1,141 @@
+from flask import request, jsonify
+from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required
+from api.models.models_user import User
+from api.database.db import db
+from api.extension import bcrypt
+from api.service.save_img import save_img
+from . import api
+
+
+@api.route('/login', methods=['POST'])
+def login():
+
+ body = request.get_json()
+
+ if not body:
+ return jsonify({
+ "success": False,
+ "msg": "Datos inválidos"
+ }), 400
+
+ email = body.get("email")
+ password = body.get("password")
+
+ if not email or not password:
+ return jsonify({
+ "success": False,
+ "msg": "Email y contraseña son obligatorios"
+ }), 400
+
+ user = User.query.filter_by(email=email).first()
+
+ if not user:
+ return jsonify({
+ "success": False,
+ "msg": "Credenciales incorrectas"
+ }), 401
+
+ if not bcrypt.check_password_hash(user.password, password):
+ return jsonify({
+ "success": False,
+ "msg": "Credenciales incorrectas"
+ }), 401
+
+ token = create_access_token(
+ identity=str(user.id)
+ )
+
+ return jsonify({
+ "success": True,
+ "token": token,
+ "user": {
+ "id": user.id,
+ "username": user.username,
+ "email": user.email
+ }
+ }), 200
+
+
+@api.route('/register', methods=['POST'])
+def register_user():
+ data = request.get_json()
+ email = data.get('email')
+ password = data.get('password')
+ username = data.get('username')
+
+ if email is None or password is None or username is None:
+ return jsonify({"msg": "i need email, password and username"}), 400
+
+ email_check = db.session.execute(db.select(User).where(User.email == email)).scalar_one_or_none()
+ if email_check is not None:
+ return jsonify({"msg": "That email is already registered"}), 400
+
+ username_check = db.session.execute(db.select(User).where(User.username == username)).scalar_one_or_none()
+ if username_check is not None:
+ return jsonify({"msg": "That username is already taken"}), 400
+
+ hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
+
+
+ new_user = User(email=email, password=hashed_password, username=username, is_active=True)
+ db.session.add(new_user)
+ db.session.commit()
+ token = create_access_token(identity=str(new_user.id))
+
+ return jsonify({"msg": "User created", "token": token, 'user': new_user.serialize()}), 201
+
+
+@api.route('/profile/eddit/', methods=['PUT'])
+@jwt_required()
+def profile_edit(id):
+
+ user_token = get_jwt_identity()
+ user_id = int(user_token)
+
+ if user_id != id:
+ return jsonify({"msg": "You do not have permission to update this profile"}), 403
+
+ user_update = db.session.get(User, id)
+
+ if user_update is None:
+ return jsonify({"msg": "User not found"}), 404
+
+ if 'img' in request.files:
+ file_to_upload = request.files['img']
+ img_url = save_img(file_to_upload)
+ if isinstance(img_url, str):
+ user_update.img = img_url
+
+ user_update.username = request.form.get('username', user_update.username)
+ user_update.email = request.form.get('email', user_update.email)
+ user_update.first_name = request.form.get('first_name', user_update.first_name)
+ user_update.last_name = request.form.get('last_name', user_update.last_name)
+ user_update.date_birth = request.form.get('date_birth', user_update.date_birth)
+ user_update.description = request.form.get('description', user_update.description)
+
+ password = request.form.get('password')
+
+ if password is not None:
+ hashed_password = bcrypt.generate_password_hash(password['password']).decode('utf-8')
+ user_update.password = hashed_password
+
+ db.session.commit()
+ return jsonify({"msg": "User updated", "user": user_update.serialize_all()}), 200
+
+@api.route("/profile/", methods=["GET"])
+@jwt_required()
+def get_user_id(id):
+
+ user_token = get_jwt_identity()
+ user_id = int(user_token)
+
+ if user_id != id:
+ return jsonify({"msg": "You do not have permission to view this profile"}), 403
+
+ user = db.session.get(User, id)
+
+ if user is None:
+ return jsonify({"msg": "User not found" }), 400
+
+ return jsonify(user.serialize_all()), 200
+
diff --git a/src/api/service/save_img.py b/src/api/service/save_img.py
new file mode 100644
index 0000000000..e67e3b9796
--- /dev/null
+++ b/src/api/service/save_img.py
@@ -0,0 +1,13 @@
+from flask import request, jsonify
+import cloudinary
+import cloudinary.uploader
+from api.cloudinary.cloudinary_config import *
+
+
+def save_img(file):
+ if file.filename != '':
+ try:
+ upload_result = cloudinary.uploader.upload(file)
+ return upload_result["secure_url"]
+ except Exception as e:
+ return jsonify({"msg": str(e)}), 500
\ No newline at end of file
diff --git a/src/app.py b/src/app.py
index 1b3340c0fa..d9a54019c9 100644
--- a/src/app.py
+++ b/src/app.py
@@ -1,72 +1,104 @@
"""
This module takes care of starting the API Server, Loading the DB and Adding the endpoints
"""
+
import os
-from flask import Flask, request, jsonify, url_for, send_from_directory
+from flask import Flask, jsonify, send_from_directory
+from flask_cors import CORS
from flask_migrate import Migrate
-from flask_swagger import swagger
+from flask_jwt_extended import JWTManager
from api.utils import APIException, generate_sitemap
-from api.models import db
+from api.models.models_user import db
from api.routes import api
from api.admin import setup_admin
from api.commands import setup_commands
+from api.extension import bcrypt
-# from models import Person
ENV = "development" if os.getenv("FLASK_DEBUG") == "1" else "production"
-static_file_dir = os.path.join(os.path.dirname(
- os.path.realpath(__file__)), '../dist/')
+
+static_file_dir = os.path.join(
+ os.path.dirname(os.path.realpath(__file__)),
+ "../dist/"
+)
+
app = Flask(__name__)
app.url_map.strict_slashes = False
-# database condiguration
+CORS(app)
+# JWT
+#app.config["JWT_SECRET_KEY"] = "mindfed-secret-key"
+app.config["JWT_SECRET_KEY"] = os.getenv("FLASK_JWT")
+# db.init_app(app)
+jwt = JWTManager(app)
+bcrypt.init_app(app)
+
+
+# Database
db_url = os.getenv("DATABASE_URL")
-if db_url is not None:
- app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace(
- "postgres://", "postgresql://")
+
+if db_url:
+ app.config["SQLALCHEMY_DATABASE_URI"] = db_url.replace(
+ "postgres://",
+ "postgresql://"
+ )
else:
- app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:////tmp/test.db"
+ app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////tmp/test.db"
+
+app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
-app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
-MIGRATE = Migrate(app, db, compare_type=True)
db.init_app(app)
+Migrate(app, db, compare_type=True)
-# add the admin
+# Admin
setup_admin(app)
-# add the admin
+# Commands
setup_commands(app)
-# Add all endpoints form the API with a "api" prefix
-app.register_blueprint(api, url_prefix='/api')
-
-# Handle/serialize errors like a JSON object
+# API
+app.register_blueprint(api, url_prefix="/api")
@app.errorhandler(APIException)
def handle_invalid_usage(error):
return jsonify(error.to_dict()), error.status_code
-# generate sitemap with all your endpoints
-
-@app.route('/')
+@app.route("/")
def sitemap():
if ENV == "development":
return generate_sitemap(app)
- return send_from_directory(static_file_dir, 'index.html')
-# any other endpoint will try to serve it like a static file
-@app.route('/', methods=['GET'])
+ return send_from_directory(
+ static_file_dir,
+ "index.html"
+ )
+
+
+@app.route("/", methods=["GET"])
def serve_any_other_file(path):
- if not os.path.isfile(os.path.join(static_file_dir, path)):
- path = 'index.html'
- response = send_from_directory(static_file_dir, path)
- response.cache_control.max_age = 0 # avoid cache memory
+
+ if not os.path.isfile(
+ os.path.join(static_file_dir, path)
+ ):
+ path = "index.html"
+
+ response = send_from_directory(
+ static_file_dir,
+ path
+ )
+
+ response.cache_control.max_age = 0
+
return response
-# this only runs if `$ python src/main.py` is executed
-if __name__ == '__main__':
- PORT = int(os.environ.get('PORT', 3001))
- app.run(host='0.0.0.0', port=PORT, debug=True)
+if __name__ == "__main__":
+ PORT = int(os.environ.get("PORT", 3001))
+
+ app.run(
+ host="0.0.0.0",
+ port=PORT,
+ debug=True
+ )
\ No newline at end of file
diff --git a/src/css/Landing.css b/src/css/Landing.css
new file mode 100644
index 0000000000..913bde9bbd
--- /dev/null
+++ b/src/css/Landing.css
@@ -0,0 +1,73 @@
+.landing-page {
+ background-color: #f8fafc;
+}
+
+.landing-left {
+ padding-right: 3rem;
+}
+
+.landing-title {
+ font-size: 3.5rem;
+ font-weight: 700;
+ color: #0f172a;
+ margin-bottom: 1rem;
+}
+
+.landing-description {
+ font-size: 1.2rem;
+ color: #64748b;
+ margin-bottom: 2rem;
+}
+
+.features {
+ margin-top: 2rem;
+}
+
+.feature-item {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ font-size: 1.1rem;
+ color: #1e293b;
+}
+
+.feature-icon {
+ color: #2563eb;
+ font-size: 1.5rem;
+ margin-right: 15px;
+}
+
+.login-card {
+ width: 450px;
+ background: white;
+ padding: 3rem;
+ border-radius: 20px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
+}
+
+.login-title {
+ text-align: center;
+ margin-bottom: 2rem;
+ color: #0f172a;
+}
+
+@media (max-width: 992px) {
+
+ .landing-left {
+ text-align: center;
+ padding-right: 0;
+ margin-bottom: 3rem;
+ }
+
+ .feature-item {
+ justify-content: center;
+ }
+
+ .login-card {
+ width: 100%;
+ }
+
+ .landing-title {
+ font-size: 2.5rem;
+ }
+}
\ No newline at end of file
diff --git a/src/css/Navbar.css b/src/css/Navbar.css
new file mode 100644
index 0000000000..6935d2332b
--- /dev/null
+++ b/src/css/Navbar.css
@@ -0,0 +1,17 @@
+.mindfed-navbar {
+ background-color: #ffffff;
+ border-bottom: 1px solid #e5e7eb;
+ height: 90px;
+ display: flex;
+ align-items: center;
+}
+
+.mindfed-logo {
+ height: 60px;
+ object-fit: contain;
+ transition: transform 0.3s ease;
+}
+
+.mindfed-logo:hover {
+ transform: scale(1.05);
+}
\ No newline at end of file
diff --git a/src/css/Post.css b/src/css/Post.css
new file mode 100644
index 0000000000..80e49e61ce
--- /dev/null
+++ b/src/css/Post.css
@@ -0,0 +1,66 @@
+.creacion-post-container {
+ min-height: 85vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.creacion-post-card {
+ border: none;
+ border-radius: 20px;
+ overflow: hidden;
+ transition: all 0.3s ease;
+}
+
+.creacion-post-card:hover {
+ transform: translateY(-4px);
+}
+
+.creacion-post-title {
+ color: #0d6efd;
+ letter-spacing: 1px;
+}
+
+.creacion-post-input,
+.creacion-post-textarea {
+ border-radius: 12px;
+}
+
+.creacion-post-textarea {
+ resize: none;
+ min-height: 150px;
+}
+
+.preview-image {
+ max-height: 250px;
+ width: auto;
+ object-fit: cover;
+ border-radius: 12px;
+}
+
+.btn-publicar,
+.btn-cancelar {
+ min-width: 130px;
+ font-weight: 600;
+}
+
+.btn-publicar {
+ transition: all 0.3s ease;
+}
+
+.btn-publicar:hover {
+ transform: translateY(-2px);
+}
+
+.btn-cancelar {
+ transition: all 0.3s ease;
+}
+
+.btn-cancelar:hover {
+ transform: translateY(-2px);
+}
+
+.form-control:focus {
+ border-color: #0d6efd;
+ box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
+}
\ No newline at end of file
diff --git a/src/front/assets/img/logomin.png b/src/front/assets/img/logomin.png
new file mode 100644
index 0000000000..51df5caf7e
Binary files /dev/null and b/src/front/assets/img/logomin.png differ
diff --git a/src/front/components/BodyTag.jsx b/src/front/components/BodyTag.jsx
new file mode 100644
index 0000000000..0d53e16f11
--- /dev/null
+++ b/src/front/components/BodyTag.jsx
@@ -0,0 +1,17 @@
+
+
+
+export const BodyTag = ({tag, onSelectedTag, isSelected}) =>{
+ const onClick = (e)=> {
+ e.preventDefault()
+ onSelectedTag(tag)
+ }
+ return(
+
+
+
+
+
{tag.title}
+
+ )
+}
\ No newline at end of file
diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx
index 30d43a2636..11ccbfe452 100644
--- a/src/front/components/Navbar.jsx
+++ b/src/front/components/Navbar.jsx
@@ -1,19 +1,18 @@
-import { Link } from "react-router-dom";
+import logo from "../assets/img/logomin.png";
+import "../../css/Navbar.css";
export const Navbar = () => {
+ return (
+
+
- return (
-
-
-
-
React Boilerplate
-
-
-
- Check the Context in action
-
-
-
-
- );
+
+
+
+
+ );
};
\ No newline at end of file
diff --git a/src/front/components/Post/Createpost.jsx b/src/front/components/Post/Createpost.jsx
new file mode 100644
index 0000000000..647f0d8514
--- /dev/null
+++ b/src/front/components/Post/Createpost.jsx
@@ -0,0 +1,135 @@
+import { useCreatePost } from "../../hooks/Hooks_post/useCreatePost";
+import "../../../css/Post.css";
+
+export const CreatePost = ({ forumId }) => {
+
+ const {
+ title,
+ setTitle,
+ content,
+ setContent,
+ setImg,
+ preview,
+ setPreview,
+ success,
+ handleSubmit
+ } = useCreatePost(forumId);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ CREACIÓN POST
+
+
+ {success && ( // ← nuevo
+
+ ✅ ¡Post creado exitosamente!
+
+ )}
+
+
+
+ setTitle(e.target.value)
+ }
+ />
+
+
{
+
+ const file =
+ e.target.files[0];
+
+ setImg(file);
+
+ if (file) {
+ setPreview(
+ URL.createObjectURL(file)
+ );
+ }
+ }}
+ />
+
+ {
+ preview && (
+
+
+
+
+
+ )
+ }
+
+
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/components/Post/PostCard.jsx b/src/front/components/Post/PostCard.jsx
new file mode 100644
index 0000000000..6ca2571541
--- /dev/null
+++ b/src/front/components/Post/PostCard.jsx
@@ -0,0 +1,32 @@
+
+
+export const Postcard = ({post}) => {
+ return(
+
+
+
+
{post.content}
+
{post.title}
+
+ {post.img &&(
+
+ )}
+
+
+
+ {new Date(
+ post.created_at
+ ).toLocaleString()}
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/front/core/constants.js b/src/front/core/constants.js
new file mode 100644
index 0000000000..fe1a045b1e
--- /dev/null
+++ b/src/front/core/constants.js
@@ -0,0 +1 @@
+export const BASE_BACK_URL = import.meta.env.VITE_BACKEND_URL;
\ No newline at end of file
diff --git a/src/front/hooks/Hooks_post/useCreatePost.js b/src/front/hooks/Hooks_post/useCreatePost.js
new file mode 100644
index 0000000000..d4d0c3f86d
--- /dev/null
+++ b/src/front/hooks/Hooks_post/useCreatePost.js
@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { createPost } from "../../../services/postService";
+
+export const useCreatePost = (forumId) => {
+
+ const [content, setContent] = useState("");
+ const [img, setImg] = useState(null);
+ const [title, setTitle] = useState("");
+ const [preview, setPreview] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+
+ const handleSubmit = async () => {
+
+ console.log("BOTÓN PUBLICAR PRESIONADO");
+
+ const token = localStorage.getItem("token");
+
+ console.log("TOKEN:", token);
+
+
+ const formData = new FormData();
+
+ formData.append("title", title);
+ formData.append("content", content);
+ formData.append("foro_id", forumId);
+
+ if (img) {
+ formData.append("img", img);
+ }
+
+ try {
+
+ const data = await createPost(
+ formData,
+ token
+ );
+
+ console.log(data);
+
+
+ setSuccess(true);
+ setTimeout(() => setSuccess(false), 3000);
+
+ setContent("");
+ setImg(null);
+
+ } catch (error) {
+
+ console.log(error);
+
+ }
+ };
+
+ return {
+ title,
+ setTitle,
+ content,
+ setContent,
+ img,
+ setImg,
+ preview,
+ setPreview,
+ success,
+ handleSubmit
+ };
+};
\ No newline at end of file
diff --git a/src/front/hooks/Hooks_post/usePost.js b/src/front/hooks/Hooks_post/usePost.js
new file mode 100644
index 0000000000..6cea3c6a6e
--- /dev/null
+++ b/src/front/hooks/Hooks_post/usePost.js
@@ -0,0 +1,51 @@
+import { useState, useEffect } from "react";
+import {getPostsForum, createPost} from "../services/postService";
+
+export const usePost = (forumId) => {
+
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const loadPosts = async () => {
+
+ try {
+
+ setLoading(true);
+
+ const data =
+ await getPostsForum(
+ forumId
+ );
+
+ console.log("Datos recibidos:", data);
+
+
+ setPosts(data);
+
+ } catch (error) {
+
+ console.log(error);
+
+ } finally {
+
+ setLoading(false);
+
+ }
+ };
+
+ useEffect(() => {
+
+ if (forumId) {
+ loadPosts();
+ }
+
+ }, [forumId]);
+
+ return {
+ posts,
+ loading,
+ loadPosts
+ };
+
+ console.log("Posts en estado:", posts);
+};
\ No newline at end of file
diff --git a/src/front/hooks/useCreateForo.js b/src/front/hooks/useCreateForo.js
new file mode 100644
index 0000000000..0ef1be47b4
--- /dev/null
+++ b/src/front/hooks/useCreateForo.js
@@ -0,0 +1,68 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { createForo } from "../../services/createForoService";
+
+export const useCreateForo = () => {
+ const navigate = useNavigate();
+
+ const [title, setTitle] = useState("");
+ const [img, setImg] = useState(null);
+ const [description, setDescription] = useState("");
+
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+
+ const handleCreateForo = async (e) => {
+ if (e) e.preventDefault();
+
+ setError("");
+ setSuccess(false);
+
+ if (!title.trim()) {
+ setError("El título del foro es obligatorio.");
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const foroData = {
+ title,
+ img,
+ description
+ };
+
+ const data = await createForo(foroData);
+
+ setSuccess(true);
+ return data;
+
+ setTitle("");
+ setImg("");
+ setDescription("");
+
+ setTimeout(() => {
+ navigate("/");
+ }, 1500);
+
+ } catch (err) {
+ setError(err.message || "Ocurrió un error al intentar crear el foro.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return {
+ title,
+ setTitle,
+ img,
+ setImg,
+ description,
+ setDescription,
+ error,
+ loading,
+ success,
+ handleCreateForo
+ };
+};
\ No newline at end of file
diff --git a/src/front/hooks/useLogin.js b/src/front/hooks/useLogin.js
new file mode 100644
index 0000000000..c109e06285
--- /dev/null
+++ b/src/front/hooks/useLogin.js
@@ -0,0 +1,46 @@
+import { useState } from "react";
+import { loginUser } from "../../services/authService";
+
+export const useLogin = () => {
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+
+ const handleLogin = async () => {
+
+ setError("");
+
+ const data = await loginUser(
+ email,
+ password
+ );
+
+ if (!data) {
+ setError("Error de conexión");
+ return;
+ }
+
+ if (!data.success) {
+ setError(data.msg);
+ return;
+ }
+
+ localStorage.setItem(
+ "token",
+ data.token
+ );
+
+ console.log("Login correcto");
+ console.log(data);
+ };
+
+ return {
+ email,
+ setEmail,
+ password,
+ setPassword,
+ error,
+ handleLogin
+ };
+};
\ No newline at end of file
diff --git a/src/front/hooks/useProfile.js b/src/front/hooks/useProfile.js
new file mode 100644
index 0000000000..87782f5cb3
--- /dev/null
+++ b/src/front/hooks/useProfile.js
@@ -0,0 +1,51 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { updateProfile } from "../../services/profileService";
+
+export const useProfile = () => {
+ const navigate = useNavigate();
+
+ const [firstName, setFirstName] = useState("");
+ const [lastName, setLastName] = useState("");
+ const [birthDate, setBirthDate] = useState("");
+ const [profileImg, setProfileImg] = useState(null);
+ const [description, setDescription] = useState("");
+
+
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+
+ const handleUpdateProfile = async () => {
+ setError("");
+ setLoading(true);
+
+ const userId = localStorage.getItem("userId");
+
+ try {
+ const profileData = { firstName, lastName, birthDate, profileImg, description };
+ const data = await updateProfile(userId, profileData);
+
+ setSuccess(true);
+ console.log("Perfil completado con éxito:", data);
+
+ navigate("/");
+ } catch (err) {
+ setError(err.message || "Error al actualizar el perfil");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return {
+ firstName, setFirstName,
+ lastName, setLastName,
+ birthDate, setBirthDate,
+ profileImg, setProfileImg,
+ description, setDescription,
+ error,
+ loading,
+ success,
+ handleUpdateProfile
+ };
+};
\ No newline at end of file
diff --git a/src/front/hooks/useRegister.js b/src/front/hooks/useRegister.js
new file mode 100644
index 0000000000..070e9ac972
--- /dev/null
+++ b/src/front/hooks/useRegister.js
@@ -0,0 +1,52 @@
+import { useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { registerUser } from "../../services/authService";
+
+export const useRegister = () => {
+ const navigate = useNavigate();
+ const [username, setUsername] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState(false);
+
+ const handleRegister = async () => {
+ setError("");
+ setSuccess(false);
+
+ try {
+ const data = await registerUser(email, password, username);
+
+ if (data && data.user && data.user.id) {
+ localStorage.setItem("userId", data.user.id);
+ }
+
+ if (data && data.token) {
+ localStorage.setItem("token", data.token);
+ }
+
+ setSuccess(true);
+ console.log("Registro correcto", data);
+ } catch (err) {
+ setError(err.message || "Error en el registro");
+ }
+ };
+
+ useEffect(() => {
+ if (success) {
+ navigate('/data-profile');
+ }
+ }, [success, navigate]);
+
+ return {
+ username,
+ setUsername,
+ email,
+ setEmail,
+ password,
+ setPassword,
+ error,
+ success,
+ handleRegister
+ };
+};
\ No newline at end of file
diff --git a/src/front/hooks/useTag.js b/src/front/hooks/useTag.js
new file mode 100644
index 0000000000..74bc8cadca
--- /dev/null
+++ b/src/front/hooks/useTag.js
@@ -0,0 +1,73 @@
+import { useState } from "react";
+import { getTags } from "../../services/tagService.js";
+import { useNavigate } from "react-router-dom";
+import { selectTagFromUser, selectTagFromForo } from "../../services/tagService.js";
+import { BsTags } from "react-icons/bs";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+export const useTag = () =>{
+
+ const { store, dispatch } = useGlobalReducer();
+ const [selectedTag, setSelectedTag] = useState([]);
+
+ const getDataTag = async () =>{
+ try{
+ const tagList = await getTags(dispatch);
+ }catch (err) {
+ setError(err.message || "Error en enlistar tags");
+ }
+ };
+
+ const onSelectedTag = async (tag) =>{
+ try{
+ if (selectedTag.includes(tag.id)){
+ setSelectedTag(selectedTag.filter(id => id !== tag.id));
+ } else {
+ setSelectedTag([...selectedTag, tag.id]); }
+
+ }catch (err) {
+ setError(err.message || "Error seleccionar tags");
+ }
+ }
+
+ const handleSave = async () => {
+ try{
+ if (setSelectedTag.length === 0 ) {
+ setError ('No hay tags seleccionadas')
+ return
+ }
+ console.log(selectedTag);
+
+ const res = await selectTagFromUser(selectedTag)
+ console.log(res);
+
+ }catch (err) {
+ setError(err.message || "Error seleccionar tags");
+ }
+ }
+
+ const handleSaveForo = async (foroId) => {
+ try{
+ if (setSelectedTag.length === 0 ) {
+ return;
+ }
+ console.log(selectedTag);
+
+ const res = await selectTagFromForo(foroId, selectedTag)
+ console.log(res);
+
+ }catch (err) {
+ setError(err.message || "Error seleccionar tags");
+ }
+ }
+
+
+ return{
+ getDataTag,
+ onSelectedTag,
+ handleSave,
+ selectedTag
+ handleSaveForo
+ };
+
+};
\ No newline at end of file
diff --git a/src/front/pages/CreacionPost.jsx b/src/front/pages/CreacionPost.jsx
new file mode 100644
index 0000000000..5bc0e73da5
--- /dev/null
+++ b/src/front/pages/CreacionPost.jsx
@@ -0,0 +1,12 @@
+
+import { CreatePost } from "../components/Post/Createpost";
+
+export const CreacionPost = () => {
+ return (
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/CreateForo.jsx b/src/front/pages/CreateForo.jsx
new file mode 100644
index 0000000000..d84275bb2b
--- /dev/null
+++ b/src/front/pages/CreateForo.jsx
@@ -0,0 +1,189 @@
+import React, { useState, useEffect } from "react";
+import { Link } from "react-router-dom";
+import { useCreateForo } from "../hooks/useCreateForo";
+import { BodyTag } from "../components/BodyTag.jsx";
+import { useTag } from "../hooks/useTag.js";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+export const CreateForo = () => {
+
+ const { store, dispatch } = useGlobalReducer()
+
+ const {
+ title,
+ setTitle,
+ img,
+ setImg,
+ description,
+ setDescription,
+ error,
+ loading,
+ success,
+ handleCreateForo
+ } = useCreateForo();
+
+
+ const {getDataTag, onSelectedTag, handleSaveForo} = useTag();
+
+ useEffect(()=>{
+ getDataTag();
+ },[]);
+
+ // const [likeCount, setLikeCount] = useState(0);
+
+ // const handleLike = () => {
+ // setLikeCount((prev) => prev + 1);
+ // };
+
+ const saveForo= async (e) => {
+ e.preventDefault()
+ try {
+ const res = await handleCreateForo();
+ const foroId = res.forum.id;
+ await handleSaveForo(foroId);
+ }catch (err) {
+ console.error("Error to save foro", err);
+ }
+ }
+
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/DataProfile.jsx b/src/front/pages/DataProfile.jsx
new file mode 100644
index 0000000000..5e998f5d40
--- /dev/null
+++ b/src/front/pages/DataProfile.jsx
@@ -0,0 +1,159 @@
+import React from "react";
+import { useEffect } from "react";
+import { useProfile } from "../hooks/useProfile";
+import { useTag } from "../hooks/useTag";
+import { BodyTag } from "../components/BodyTag.jsx";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+
+export const DataProfile = () => {
+
+ const { store, dispatch } = useGlobalReducer()
+
+ const { getDataTag, onSelectedTag, handleSave, selectedTag } = useTag();
+
+ useEffect(() => {
+ getDataTag();
+ }, []);
+
+ const {
+ firstName, setFirstName,
+ lastName, setLastName,
+ birthDate, setBirthDate,
+ profileImg, setProfileImg,
+ description, setDescription,
+ error,
+ loading,
+ success,
+ handleUpdateProfile
+ } = useProfile();
+
+ const saveProfile = async (e) => {
+ e.preventDefault()
+ try {
+ const res = await handleUpdateProfile();
+ const resTag = await handleSave();
+ } catch (err) {
+ console.error("Error to save profile", err);
+ }
+ }
+
+ return (
+
+
+
+
+
Completa tu Perfil
+
+ {error &&
{error}
}
+ {success &&
¡Perfil actualizado con éxito!
}
+
+
+
+
+
+
+ Fecha de Nacimiento
+ setBirthDate(e.target.value)}
+ />
+
+
+
+ {profileImg ? (
+
{
+ e.target.src = "https://placehold.co/65?text=Foro";
+ }}
+ />) : (
+ 💬
+
)}
+
URL de la Imagen de Perfil
+
setProfileImg(e.target.files[0])}
+ />
+
+
+
+ Descripción
+ setDescription(e.target.value)}
+ >
+
+
+
+
Selecciona tus gustos
+
+ {store.tags && store.tags.map((tag) => {
+ const handleSelected = selectedTag.includes(tag.id);
+ return ( );
+ })}
+
+
+
+
+
+ {loading ? "Guardando..." : "Guardar Perfil"}
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Home.jsx b/src/front/pages/Home.jsx
deleted file mode 100644
index 341ed21768..0000000000
--- a/src/front/pages/Home.jsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import React, { useEffect } from "react"
-import rigoImageUrl from "../assets/img/rigo-baby.jpg";
-import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
-
-export const Home = () => {
-
- const { store, dispatch } = useGlobalReducer()
-
- const loadMessage = async () => {
- try {
- const backendUrl = import.meta.env.VITE_BACKEND_URL
-
- if (!backendUrl) throw new Error("VITE_BACKEND_URL is not defined in .env file")
-
- const response = await fetch(backendUrl + "/api/hello")
- const data = await response.json()
-
- if (response.ok) dispatch({ type: "set_hello", payload: data.message })
-
- return data
-
- } catch (error) {
- if (error.message) throw new Error(
- `Could not fetch the message from the backend.
- Please check if the backend is running and the backend port is public.`
- );
- }
-
- }
-
- useEffect(() => {
- loadMessage()
- }, [])
-
- return (
-
-
Hello Rigo!!
-
-
-
-
- {store.message ? (
- {store.message}
- ) : (
-
- Loading message from the backend (make sure your python 🐍 backend is running)...
-
- )}
-
-
- );
-};
\ No newline at end of file
diff --git a/src/front/pages/Landing.jsx b/src/front/pages/Landing.jsx
new file mode 100644
index 0000000000..ae75d0f154
--- /dev/null
+++ b/src/front/pages/Landing.jsx
@@ -0,0 +1,126 @@
+import { Link } from "react-router-dom";
+import { useLogin } from "../hooks/useLogin";
+import {
+ FaUsers,
+ FaComments,
+ FaGlobe,
+ FaLightbulb
+} from "react-icons/fa";
+
+import "../../css/Landing.css"
+
+export const Landing = () => {
+
+ const {
+ email,
+ setEmail,
+ password,
+ setPassword,
+ error,
+ handleLogin
+ } = useLogin();
+
+ return (
+
+
+
+
+
+ {/* Parte Izquierda */}
+
+
+
+
+ Bienvenido a MindFed
+
+
+
+ La comunidad donde puedes compartir ideas,
+ debatir y conectar con personas que comparten
+ tus mismos intereses.
+
+
+
+
+
+
+ Comunidades activas
+
+
+
+
+ Debates en tiempo real
+
+
+
+
+ Películas, deportes, videojuegos y más
+
+
+
+
+ Comparte tus ideas
+
+
+
+
+
+
+ {/* Parte Derecha */}
+
+
+
+
+
+
+ Iniciar Sesión
+
+
+
setEmail(e.target.value)}
+ />
+
+
setPassword(e.target.value)}
+ />
+
+ {
+ error && (
+
+ {error}
+
+ )
+ }
+
+
+ Entrar
+
+
+
+ Registrarse
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/RegisterForm.jsx b/src/front/pages/RegisterForm.jsx
new file mode 100644
index 0000000000..6c86859ef7
--- /dev/null
+++ b/src/front/pages/RegisterForm.jsx
@@ -0,0 +1,100 @@
+import React, { useState } from "react";
+import { useRegister } from "../hooks/useRegister";
+import { Link } from "react-router-dom";
+
+export const RegisterForm = () => {
+ const {
+ username,
+ setUsername,
+ email,
+ setEmail,
+ password,
+ setPassword,
+ error,
+ handleRegister
+ } = useRegister();
+
+ const handleSubmit = (event) => {
+ event.preventDefault();
+ handleRegister();
+ };
+
+ return (
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ setUsername(e.target.value)}
+ required
+ />
+
+ Username
+
+
+
+
+
setEmail(e.target.value)}
+ required
+ />
+
+ Email Address
+
+
+ We'll never share your email with anyone.
+
+
+
+
+ setPassword(e.target.value)}
+ required
+ />
+
+ Password
+
+
+
+
+ Create Account
+
+
+
+
+
+
+ Already have an account? Log In
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/routes.jsx b/src/front/routes.jsx
index 0557df6141..9338c372da 100644
--- a/src/front/routes.jsx
+++ b/src/front/routes.jsx
@@ -6,9 +6,14 @@ import {
Route,
} from "react-router-dom";
import { Layout } from "./pages/Layout";
-import { Home } from "./pages/Home";
+import { Landing } from "./pages/Landing";
+import { CreacionPost } from "./pages/CreacionPost";
import { Single } from "./pages/Single";
import { Demo } from "./pages/Demo";
+import { RegisterForm } from "./pages/RegisterForm"
+import { DataProfile } from "./pages/DataProfile"
+import { CreateForo } from "./pages/CreateForo";
+
export const router = createBrowserRouter(
createRoutesFromElements(
@@ -19,12 +24,16 @@ export const router = createBrowserRouter(
// Note: The child paths of the Layout element replace the Outlet component with the elements contained in the "element" attribute of these child paths.
// Root Route: All navigation will start from here.
+ <>
+ } />
} errorElement={Not found! } >
-
{/* Nested Routes: Defines sub-routes within the BaseHome component. */}
- } />
} /> {/* Dynamic route for single items */}
- } />
+ } />
+ } />
+ } />
+ } />
+ >
)
);
\ No newline at end of file
diff --git a/src/front/store.js b/src/front/store.js
index 3062cd222d..7355a01b65 100644
--- a/src/front/store.js
+++ b/src/front/store.js
@@ -1,38 +1,19 @@
export const initialStore=()=>{
return{
- message: null,
- todos: [
- {
- id: 1,
- title: "Make the bed",
- background: null,
- },
- {
- id: 2,
- title: "Do my homework",
- background: null,
- }
- ]
+ tags: []
}
}
export default function storeReducer(store, action = {}) {
switch(action.type){
- case 'set_hello':
- return {
+ case 'all_tags':
+ return {
...store,
- message: action.payload
- };
-
- case 'add_task':
+ tags: action.payload
+ };
- const { id, color } = action.payload
-
- return {
- ...store,
- todos: store.todos.map((todo) => (todo.id === id ? { ...todo, background: color } : todo))
- };
+
default:
- throw Error('Unknown action.');
+ throw Error('Unknown action.');
}
}
diff --git a/src/services/authService.js b/src/services/authService.js
new file mode 100644
index 0000000000..ff531afa49
--- /dev/null
+++ b/src/services/authService.js
@@ -0,0 +1,61 @@
+const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
+
+export const loginUser = async (email, password) => {
+ try {
+ const response = await fetch(`${BACKEND_URL}/api/login`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ email,
+ password,
+ }),
+ });
+
+ const data = await response.json();
+
+ return data;
+ } catch (error) {
+ console.error("Error en login:", error);
+ return null;
+ }
+};
+
+export const registerUser = async (email, password, username) => {
+ if (!email?.trim() || !password?.trim()) {
+ throw new Error("Email and password are required.");
+ }
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ throw new Error("Invalid email format.");
+ }
+
+ if (password.length < 6) {
+ throw new Error("Password must be at least 6 characters long.");
+ }
+
+ try {
+ const response = await fetch(`${BACKEND_URL}/api/register`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ email, password, username }),
+ });
+
+ const textData = await response.text();
+ const data = textData ? JSON.parse(textData) : {};
+
+ if (!response.ok) {
+ const errorMessage = data.message || `Registration failed (Status: ${response.status})`;
+ throw new Error(errorMessage);
+ }
+
+ return data;
+ } catch (error) {
+ console.error("Error in registerUser:", error.message);
+ throw error;
+ }
+};
\ No newline at end of file
diff --git a/src/services/createForoService.js b/src/services/createForoService.js
new file mode 100644
index 0000000000..85dad4e137
--- /dev/null
+++ b/src/services/createForoService.js
@@ -0,0 +1,81 @@
+const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
+
+export const createForo = async (foroData) => {
+ const token = localStorage.getItem("token");
+
+ if (!token) {
+ throw new Error("No se encontró el token de autorización. Por favor, inicia sesión.");
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append("title", foroData.title);
+ formData.append("description", foroData.description || "");
+ if (foroData.img) {
+ formData.append("img", foroData.img);
+ }
+
+ const response = await fetch(`${BACKEND_URL}/api/foro`, {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${token}`
+
+ },
+ body: formData
+ });
+
+ const textData = await response.text();
+ const data = textData ? JSON.parse(textData) : {};
+
+ if (!response.ok) {
+ const errorMessage = data.msg || `Error al crear el foro (Estado: ${response.status})`;
+ throw new Error(errorMessage);
+ }
+
+ return data;
+ } catch (error) {
+ throw error;
+ }
+};
+
+export const getForos = async () => {
+ try {
+ const response = await fetch(`${BACKEND_URL}/api/foros`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json"
+ }
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.msg || "Error al obtener los foros");
+ }
+
+ return data;
+ } catch (error) {
+ throw error;
+ }
+};
+
+export const getForoById = async (foroId) => {
+ try {
+ const response = await fetch(`${BACKEND_URL}/api/foro/${foroId}`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json"
+ }
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.msg || "Foro no encontrado");
+ }
+
+ return data;
+ } catch (error) {
+ throw error;
+ }
+};
\ No newline at end of file
diff --git a/src/services/postService.js b/src/services/postService.js
new file mode 100644
index 0000000000..e8871a7b4d
--- /dev/null
+++ b/src/services/postService.js
@@ -0,0 +1,37 @@
+const BASE_URL = import.meta.env.VITE_BACKEND_URL;
+
+export const createPost = async (formData, token) => {
+ const response = await fetch(`${BASE_URL}/api/post`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`
+ },
+ body: formData
+ });
+
+ return await response.json();
+};
+
+export const getPostsForum = async (forumId) => {
+ const response = await fetch(
+ `${BASE_URL}/api/foro/${forumId}/posts`
+ );
+
+ return await response.json();
+};
+
+export const getTopPosts = async (forumId) => {
+ const response = await fetch(
+ `${BASE_URL}/api/foro/${forumId}/posts/top`
+ );
+
+ return await response.json();
+};
+
+export const getPostById = async (postId) => {
+ const response = await fetch(
+ `${BASE_URL}/api/post/${postId}`
+ );
+
+ return await response.json();
+};
\ No newline at end of file
diff --git a/src/services/profileService.js b/src/services/profileService.js
new file mode 100644
index 0000000000..43ffc7c8e4
--- /dev/null
+++ b/src/services/profileService.js
@@ -0,0 +1,45 @@
+const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
+
+export const updateProfile = async (userId, profileData) => {
+ if (!userId) {
+ throw new Error("El ID de usuario es obligatorio para actualizar el perfil.");
+ }
+
+ const token = localStorage.getItem("token");
+
+ if (!token) {
+ throw new Error("No se encontró el token de autorización. Por favor, inicia sesión.");
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append("first_name", profileData.firstName);
+ formData.append("last_name", profileData.lastName);
+ formData.append("date_birth", profileData.birthDate);
+ formData.append("description", profileData.description);
+ if (profileData.profileImg){
+ formData.append("img", profileData.profileImg)
+ }
+
+ const response = await fetch(`${BACKEND_URL}/api/profile/eddit/${userId}`, {
+ method: "PUT",
+ headers: {
+ "Authorization": `Bearer ${token}`
+ },
+ body: formData
+ });
+
+ const textData = await response.text();
+ const data = textData ? JSON.parse(textData) : {};
+
+ if (!response.ok) {
+ const errorMessage = data.msg || `Error en la actualización (Estado: ${response.status})`;
+ throw new Error(errorMessage);
+ }
+
+ return data;
+ } catch (error) {
+ console.error("Error en updateProfile:", error.message);
+ throw error;
+ }
+};
\ No newline at end of file
diff --git a/src/services/tagService.js b/src/services/tagService.js
new file mode 100644
index 0000000000..8ffd7cb5f9
--- /dev/null
+++ b/src/services/tagService.js
@@ -0,0 +1,65 @@
+const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
+
+export const getTags = async (dispatch) => {
+ try {
+ const token = localStorage.getItem("token");
+ const res = await fetch(`${BACKEND_URL}/api/tag`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`,
+ }
+ });
+ const data = await res.json();
+ dispatch({
+ type: "all_tags",
+ payload: data.tag
+ });
+ } catch (err) {
+ console.error("Error to get tags", err);
+ }
+};
+
+export const selectTagFromUser = async (selectTags) => {
+ try {
+ const token = localStorage.getItem("token");
+ const res = await fetch(`${BACKEND_URL}/api/tag-select`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ tags_id: selectTags
+ })
+ });
+ const data = await res.json();
+ } catch (err) {
+ console.error("Error to get tags", err);
+ }
+}
+
+export const selectTagFromForo = async (foroId, tagsId) => {
+ try {
+ const token = localStorage.getItem("token");
+ const res = await fetch(`${BACKEND_URL}/api/tag-select-foro`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ foro_id: foroId,
+ tags_id: tagsId
+ })
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.msg || "Error al asignar tags al foro");
+ }
+ return data;
+ } catch (err) {
+ console.error("Error to get tags", err);
+ throw err;
+ }
+}
\ No newline at end of file